Compare commits
29 Commits
78d3052a61
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63f8ee333f | |||
| 0241d186ea | |||
| 46892ee61c | |||
| 8318e4b560 | |||
| 626f2845e2 | |||
| 502e339809 | |||
| dca97bd69b | |||
| 061873058e | |||
| c98c7c8ce2 | |||
| fd8571ad10 | |||
| c7e5efa976 | |||
| 4da13c0811 | |||
| 4cad7f1c46 | |||
| 700a684257 | |||
| 2b2ff92146 | |||
| d34c8a73d6 | |||
| 9ba2aa3f38 | |||
| 209b3e1485 | |||
| 3b51cb89df | |||
| 861502aabc | |||
| 327bea6f33 | |||
| 87d9ba2299 | |||
| de1bcc5cae | |||
| 2f6e1d5e51 | |||
| 0d828257c0 | |||
| 8b8f2fc6c5 | |||
| 7aad3b1228 | |||
| 8ce00ede31 | |||
| 267061e226 |
@@ -24,11 +24,19 @@ CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
|
|||||||
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
|
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
|
||||||
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
||||||
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
|
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
|
||||||
|
# Публичные узлы разрешены по умолчанию. Для внутренних API перечислите
|
||||||
|
# допустимые имена или IP через запятую.
|
||||||
|
CRANK_OUTBOUND_ALLOWED_HOSTS=
|
||||||
|
CRANK_OUTBOUND_DENIED_HOSTS=
|
||||||
|
CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304
|
||||||
CRANK_LOG_LEVEL=info
|
CRANK_LOG_LEVEL=info
|
||||||
CRANK_MASTER_KEY=change-me-master-key
|
CRANK_MASTER_KEY=change-me-master-key
|
||||||
CRANK_SESSION_SECRET=change-me-session-secret
|
CRANK_SESSION_SECRET=change-me-session-secret
|
||||||
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||||
CRANK_SESSION_TTL_HOURS=24
|
CRANK_SESSION_TTL_HOURS=24
|
||||||
|
# Trust X-Real-IP / X-Forwarded-For for client rate limiting. Enable only when
|
||||||
|
# admin-api runs behind the bundled nginx (or another trusted reverse proxy).
|
||||||
|
CRANK_TRUST_FORWARDED_HEADERS=true
|
||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||||
|
|||||||
@@ -22,6 +22,29 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
||||||
|
if [ -z "$toolchain" ]; then
|
||||||
|
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
||||||
|
rustup default "$toolchain"
|
||||||
|
host="$(rustc -vV | sed -n 's/^host: //p')"
|
||||||
|
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
||||||
|
toolchain_bin="$toolchain_dir/bin"
|
||||||
|
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||||
|
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||||
|
"$toolchain_bin/rustc" --version
|
||||||
|
"$toolchain_bin/cargo" --version
|
||||||
|
"$toolchain_bin/rustfmt" --version
|
||||||
|
"$toolchain_bin/cargo-clippy" --version
|
||||||
|
|
||||||
- name: Verify runner toolchain
|
- name: Verify runner toolchain
|
||||||
run: |
|
run: |
|
||||||
python3 --version
|
python3 --version
|
||||||
@@ -104,6 +127,29 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
||||||
|
if [ -z "$toolchain" ]; then
|
||||||
|
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
||||||
|
rustup default "$toolchain"
|
||||||
|
host="$(rustc -vV | sed -n 's/^host: //p')"
|
||||||
|
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
||||||
|
toolchain_bin="$toolchain_dir/bin"
|
||||||
|
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||||
|
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||||
|
"$toolchain_bin/rustc" --version
|
||||||
|
"$toolchain_bin/cargo" --version
|
||||||
|
"$toolchain_bin/rustfmt" --version
|
||||||
|
"$toolchain_bin/cargo-clippy" --version
|
||||||
|
|
||||||
- name: Verify runner toolchain
|
- name: Verify runner toolchain
|
||||||
run: |
|
run: |
|
||||||
rustc --version
|
rustc --version
|
||||||
@@ -256,6 +302,9 @@ jobs:
|
|||||||
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
|
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
|
||||||
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
|
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
|
||||||
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
|
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
|
||||||
|
append_if_set CRANK_OUTBOUND_ALLOWED_HOSTS "${CRANK_OUTBOUND_ALLOWED_HOSTS:-}"
|
||||||
|
append_if_set CRANK_OUTBOUND_DENIED_HOSTS "${CRANK_OUTBOUND_DENIED_HOSTS:-}"
|
||||||
|
append_if_set CRANK_OUTBOUND_MAX_RESPONSE_BYTES "${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-}"
|
||||||
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
||||||
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
|
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
|
||||||
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
|
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Use preinstalled Rust toolchain
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/1.96.1-x86_64-unknown-linux-gnu"
|
||||||
|
toolchain_bin="$toolchain_dir/bin"
|
||||||
|
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||||
|
echo "Rust 1.96.1 is not preinstalled at $toolchain_dir." >&2
|
||||||
|
echo "Install it in the Gitea runner image/host before running CI:" >&2
|
||||||
|
echo "rustup toolchain install 1.96.1 --profile minimal --component clippy --component rustfmt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||||
|
"$toolchain_bin/rustc" --version
|
||||||
|
"$toolchain_bin/cargo" --version
|
||||||
|
"$toolchain_bin/rustfmt" --version
|
||||||
|
"$toolchain_bin/cargo-clippy" --version
|
||||||
|
|
||||||
- name: Verify runner toolchain
|
- name: Verify runner toolchain
|
||||||
run: |
|
run: |
|
||||||
rustc --version
|
rustc --version
|
||||||
|
|||||||
Generated
+564
-869
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -18,28 +18,28 @@ resolver = "3"
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
rust-version = "1.85"
|
rust-version = "1.96"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
rand = "0.8"
|
rand = "0.10"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json", "time"] }
|
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
time = { version = "0.3", features = ["formatting", "parsing", "serde"] }
|
time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
uuid = { version = "1", features = ["serde", "v7"] }
|
uuid = { version = "1", features = ["serde", "v7"] }
|
||||||
testcontainers = { version = "0.25.0", features = ["blocking"] }
|
testcontainers = { version = "0.27", features = ["blocking"] }
|
||||||
testcontainers-modules = { version = "0.13.0", features = ["postgres", "blocking"] }
|
testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.85-bookworm AS deps
|
FROM rust:1.96.1-bookworm AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
--mount=type=cache,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
SQLX_OFFLINE=true cargo build --release -p admin-api
|
SQLX_OFFLINE=true cargo build --release -p admin-api
|
||||||
|
|
||||||
FROM rust:1.85-bookworm AS builder
|
FROM rust:1.96.1-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ use crate::{
|
|||||||
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
||||||
capabilities::get_capabilities,
|
capabilities::get_capabilities,
|
||||||
imports::{create_openapi_import, preview_openapi_import},
|
imports::{create_openapi_import, preview_openapi_import},
|
||||||
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
observability::{
|
||||||
|
get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals,
|
||||||
|
list_logs,
|
||||||
|
},
|
||||||
operations::{
|
operations::{
|
||||||
analyze_operation_quality, archive_operation, create_operation, create_version,
|
analyze_operation_quality, archive_operation, create_operation, create_version,
|
||||||
delete_operation, export_operation, generate_draft, get_operation,
|
delete_operation, export_operation, generate_draft, get_operation,
|
||||||
@@ -123,6 +126,8 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.route("/export", get(export_workspace))
|
.route("/export", get(export_workspace))
|
||||||
.route("/logs", get(list_logs))
|
.route("/logs", get(list_logs))
|
||||||
.route("/logs/{log_id}", get(get_log))
|
.route("/logs/{log_id}", get(get_log))
|
||||||
|
.route("/approvals", get(list_approvals))
|
||||||
|
.route("/approvals/{approval_id}", get(get_approval))
|
||||||
.route("/usage", get(get_usage))
|
.route("/usage", get(get_usage))
|
||||||
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
||||||
.route("/usage/agents/{agent_id}", get(get_agent_usage));
|
.route("/usage/agents/{agent_id}", get(get_agent_usage));
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
AgentId, AgentStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft,
|
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
|
||||||
InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus,
|
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
|
||||||
PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod,
|
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
|
||||||
WizardState, WorkspaceId, WorkspaceStatus,
|
UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
|
||||||
};
|
};
|
||||||
use crank_mapping::MappingSet;
|
use crank_mapping::MappingSet;
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
@@ -250,6 +250,12 @@ pub struct LogsQuery {
|
|||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct ApprovalsQuery {
|
||||||
|
pub status: Option<ApprovalRequestStatus>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct UsageRequestQuery {
|
pub struct UsageRequestQuery {
|
||||||
pub period: Option<UsagePeriod>,
|
pub period: Option<UsagePeriod>,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crank_community_auth::PasswordIdentityProvider;
|
|||||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||||
use crank_runtime::{
|
use crank_runtime::{
|
||||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||||
RuntimeLimits, SecretCrypto, community_default,
|
RuntimeLimits, SecretCrypto,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgConnectOptions;
|
use sqlx::postgres::PgConnectOptions;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -56,7 +56,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||||
let runtime = community_default()
|
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::from_env()?;
|
||||||
|
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
|
||||||
.with_limits(runtime_limits)
|
.with_limits(runtime_limits)
|
||||||
.with_response_cache(cache_stores.response.clone())
|
.with_response_cache(cache_stores.response.clone())
|
||||||
.with_coordination_store(cache_stores.coordination.clone())
|
.with_coordination_store(cache_stores.coordination.clone())
|
||||||
@@ -70,6 +71,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
secret_crypto,
|
secret_crypto,
|
||||||
runtime,
|
runtime,
|
||||||
)
|
)
|
||||||
|
.with_outbound_http_policy(outbound_http_policy)
|
||||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||||
.build();
|
.build();
|
||||||
service.bootstrap_admin_user().await?;
|
service.bootstrap_admin_user().await?;
|
||||||
@@ -83,9 +85,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
} else {
|
} else {
|
||||||
RequestRateLimiter::new(api_rate_limit)
|
RequestRateLimiter::new(api_rate_limit)
|
||||||
},
|
},
|
||||||
|
trust_forwarded_headers: env_flag("CRANK_TRUST_FORWARDED_HEADERS"),
|
||||||
};
|
};
|
||||||
let app = build_app(state);
|
let app = build_app(state);
|
||||||
let listener = TcpListener::bind(socket_addr).await?;
|
let listener = TcpListener::bind(socket_addr).await?;
|
||||||
|
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||||
@@ -101,7 +105,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
info!("admin-api listening on {}", socket_addr);
|
info!("admin-api listening on {}", socket_addr);
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, make_service).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Request, State},
|
extract::{ConnectInfo, Request, State},
|
||||||
http::header::{COOKIE, HeaderMap},
|
http::HeaderMap,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use crank_runtime::RateLimitRejection;
|
use crank_runtime::RateLimitRejection;
|
||||||
|
|
||||||
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
pub async fn apply_api_rate_limit(
|
pub async fn apply_api_rate_limit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let key = rate_limit_key(request.headers(), request.uri().path());
|
let peer_ip = request
|
||||||
|
.extensions()
|
||||||
|
.get::<ConnectInfo<SocketAddr>>()
|
||||||
|
.map(|ConnectInfo(address)| address.ip());
|
||||||
|
let key = rate_limit_key(
|
||||||
|
request.headers(),
|
||||||
|
request.uri().path(),
|
||||||
|
peer_ip,
|
||||||
|
state.trust_forwarded_headers,
|
||||||
|
);
|
||||||
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
|
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
|
||||||
return Err(ApiError::rate_limited_with_context(
|
return Err(ApiError::rate_limited_with_context(
|
||||||
"request rate limit exceeded",
|
"request rate limit exceeded",
|
||||||
@@ -30,56 +41,64 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
|
fn rate_limit_key(
|
||||||
if let Some(session_id) = session_id_from_headers(headers) {
|
headers: &HeaderMap,
|
||||||
return format!("session:{session_id}");
|
path: &str,
|
||||||
|
peer_ip: Option<IpAddr>,
|
||||||
|
trust_forwarded_headers: bool,
|
||||||
|
) -> String {
|
||||||
|
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
|
||||||
|
return format!("ip:{client_ip}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") {
|
if let Some(peer_ip) = peer_ip {
|
||||||
let ip = forwarded_for
|
return format!("ip:{peer_ip}");
|
||||||
.split(',')
|
|
||||||
.next()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
return format!("ip:{ip}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(real_ip) = header_value(headers, "x-real-ip") {
|
|
||||||
return format!("ip:{real_ip}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
format!("anonymous:{path}")
|
format!("anonymous:{path}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> {
|
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
|
||||||
let cookies = headers.get(COOKIE)?.to_str().ok()?;
|
///
|
||||||
for part in cookies.split(';') {
|
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
|
||||||
let (name, value) = part.trim().split_once('=')?;
|
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
|
||||||
if name != SESSION_COOKIE_NAME {
|
/// peer, so the last entry is the trustworthy hop; taking the first entry (as
|
||||||
continue;
|
/// naive implementations do) would let a client spoof its address by sending a
|
||||||
}
|
/// pre-populated header.
|
||||||
let (session_id, _) = value.split_once('.')?;
|
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||||
if !session_id.is_empty() {
|
if let Some(real_ip) = header_value(headers, "x-real-ip").and_then(parse_ip) {
|
||||||
return Some(session_id.to_owned());
|
return Some(real_ip);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
header_value(headers, "x-forwarded-for")?
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.rfind(|value| !value.is_empty())
|
||||||
|
.and_then(parse_ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
||||||
headers.get(name)?.to_str().ok().map(str::trim)
|
headers.get(name)?.to_str().ok().map(str::trim)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_ip(value: &str) -> Option<IpAddr> {
|
||||||
|
value.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||||
|
|
||||||
use super::rate_limit_key;
|
use super::rate_limit_key;
|
||||||
|
|
||||||
|
fn peer() -> Option<IpAddr> {
|
||||||
|
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_by_session_cookie_first() {
|
fn unverified_session_cookie_cannot_change_client_key() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
COOKIE,
|
COOKIE,
|
||||||
@@ -88,19 +107,86 @@ mod tests {
|
|||||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rate_limit_key(&headers, "/api/auth/login"),
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||||
"session:sess_123"
|
"ip:10.0.0.5"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_forwarded_ip() {
|
fn ignores_forwarded_headers_when_untrusted() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||||
|
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_key(&headers, "/api/auth/login", peer(), false),
|
||||||
|
"ip:203.0.113.7"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefers_real_ip_when_trusted() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
"x-forwarded-for",
|
"x-forwarded-for",
|
||||||
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
|
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
||||||
|
);
|
||||||
|
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||||
|
"ip:10.0.0.9"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_last_forwarded_hop_when_trusted() {
|
||||||
|
// A client can prepend spoofed entries; the trusted proxy appends the
|
||||||
|
// real peer, so the last entry is authoritative.
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-forwarded-for",
|
||||||
|
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
|
assert_eq!(
|
||||||
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||||
|
"ip:10.0.0.6"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_to_peer_ip_without_headers() {
|
||||||
|
let headers = HeaderMap::new();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||||
|
"ip:203.0.113.7"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_invalid_forwarded_ip_values() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
|
||||||
|
headers.insert(
|
||||||
|
"x-forwarded-for",
|
||||||
|
HeaderValue::from_static("198.51.100.8, also-not-an-ip"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||||
|
"ip:203.0.113.7"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_to_path_without_peer() {
|
||||||
|
let headers = HeaderMap::new();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_key(&headers, "/api/auth/login", None, false),
|
||||||
|
"anonymous:/api/auth/login"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
|||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
routes::access::WorkspacePath,
|
routes::access::WorkspacePath,
|
||||||
service::{LogsQuery, UsageRequestQuery},
|
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,6 +17,12 @@ pub struct WorkspaceLogPath {
|
|||||||
pub log_id: String,
|
pub log_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct WorkspaceApprovalPath {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub approval_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct WorkspaceOperationUsagePath {
|
pub struct WorkspaceOperationUsagePath {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -55,6 +61,32 @@ pub async fn get_log(
|
|||||||
Ok(Json(json!(item)))
|
Ok(Json(json!(item)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_approvals(
|
||||||
|
Path(path): Path<WorkspacePath>,
|
||||||
|
Query(query): Query<ApprovalsQuery>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let items = state
|
||||||
|
.service
|
||||||
|
.list_approvals(&path.workspace_id.as_str().into(), query)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_approval(
|
||||||
|
Path(path): Path<WorkspaceApprovalPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let item = state
|
||||||
|
.service
|
||||||
|
.get_approval(
|
||||||
|
&path.workspace_id.as_str().into(),
|
||||||
|
&path.approval_id.as_str().into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!(item)))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_usage(
|
pub async fn get_usage(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
Query(query): Query<UsageRequestQuery>,
|
Query(query): Query<UsageRequestQuery>,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ use crank_registry::{
|
|||||||
AgentSummary, CreateInvocationLogRequest, OperationAgentRef, OperationSummary,
|
AgentSummary, CreateInvocationLogRequest, OperationAgentRef, OperationSummary,
|
||||||
OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
|
OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
|
||||||
};
|
};
|
||||||
use crank_runtime::{PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto};
|
use crank_runtime::{
|
||||||
|
OutboundHttpPolicy, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
|
||||||
|
};
|
||||||
use crank_schema::{Schema, SchemaKind};
|
use crank_schema::{Schema, SchemaKind};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -38,8 +40,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_approval_policy, validate_idempotency_policy, validate_protocol_target,
|
validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
|
||||||
validate_response_cache_policy,
|
validate_protocol_target, validate_response_cache_policy,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -53,6 +55,7 @@ pub struct AdminService {
|
|||||||
policy_engine: Arc<dyn PolicyEngine>,
|
policy_engine: Arc<dyn PolicyEngine>,
|
||||||
audit_sink: Arc<dyn AuditSink>,
|
audit_sink: Arc<dyn AuditSink>,
|
||||||
capability_profile: Arc<dyn CapabilityProfile>,
|
capability_profile: Arc<dyn CapabilityProfile>,
|
||||||
|
outbound_http_policy: OutboundHttpPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AdminServiceBuilder {
|
pub struct AdminServiceBuilder {
|
||||||
@@ -65,6 +68,7 @@ pub struct AdminServiceBuilder {
|
|||||||
policy_engine: Option<Arc<dyn PolicyEngine>>,
|
policy_engine: Option<Arc<dyn PolicyEngine>>,
|
||||||
audit_sink: Option<Arc<dyn AuditSink>>,
|
audit_sink: Option<Arc<dyn AuditSink>>,
|
||||||
capability_profile: Option<Arc<dyn CapabilityProfile>>,
|
capability_profile: Option<Arc<dyn CapabilityProfile>>,
|
||||||
|
outbound_http_policy: OutboundHttpPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use crate::dto::*;
|
pub use crate::dto::*;
|
||||||
@@ -139,6 +143,7 @@ impl AdminServiceBuilder {
|
|||||||
policy_engine: None,
|
policy_engine: None,
|
||||||
audit_sink: None,
|
audit_sink: None,
|
||||||
capability_profile: None,
|
capability_profile: None,
|
||||||
|
outbound_http_policy: OutboundHttpPolicy::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +152,11 @@ impl AdminServiceBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_outbound_http_policy(mut self, policy: OutboundHttpPolicy) -> Self {
|
||||||
|
self.outbound_http_policy = policy;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self {
|
pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self {
|
||||||
self.policy_engine = Some(policy_engine);
|
self.policy_engine = Some(policy_engine);
|
||||||
@@ -183,6 +193,7 @@ impl AdminServiceBuilder {
|
|||||||
capability_profile: self
|
capability_profile: self
|
||||||
.capability_profile
|
.capability_profile
|
||||||
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
|
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
|
||||||
|
outbound_http_policy: self.outbound_http_policy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,6 +308,8 @@ impl AdminService {
|
|||||||
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
|
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
|
||||||
self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
|
self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
|
||||||
validate_protocol_target(payload.protocol, &payload.target)?;
|
validate_protocol_target(payload.protocol, &payload.target)?;
|
||||||
|
self.validate_outbound_target(&payload.target)?;
|
||||||
|
validate_execution_timeout(&payload.execution_config)?;
|
||||||
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)?;
|
validate_approval_policy(&payload.execution_config)?;
|
||||||
@@ -308,6 +321,8 @@ impl AdminService {
|
|||||||
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
|
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
|
||||||
self.validate_operation_capabilities(operation.protocol, operation.security_level)?;
|
self.validate_operation_capabilities(operation.protocol, operation.security_level)?;
|
||||||
validate_protocol_target(operation.protocol, &operation.target)?;
|
validate_protocol_target(operation.protocol, &operation.target)?;
|
||||||
|
self.validate_outbound_target(&operation.target)?;
|
||||||
|
validate_execution_timeout(&operation.execution_config)?;
|
||||||
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)?;
|
validate_approval_policy(&operation.execution_config)?;
|
||||||
@@ -316,6 +331,15 @@ impl AdminService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_outbound_target(&self, target: &crank_core::Target) -> Result<(), ApiError> {
|
||||||
|
match target {
|
||||||
|
crank_core::Target::Rest(rest) => self
|
||||||
|
.outbound_http_policy
|
||||||
|
.validate_base_url(&rest.base_url)
|
||||||
|
.map_err(|error| ApiError::validation(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_operation_capabilities(
|
fn validate_operation_capabilities(
|
||||||
&self,
|
&self,
|
||||||
protocol: Protocol,
|
protocol: Protocol,
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
use crank_core::{AgentId, InvocationLogId, OperationId, UsagePeriod, WorkspaceId};
|
use crank_core::{
|
||||||
use crank_registry::{InvocationLogRecord, ListInvocationLogsQuery, UsageQuery, UsageRollupRecord};
|
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
|
||||||
|
WorkspaceId,
|
||||||
|
};
|
||||||
|
use crank_registry::{
|
||||||
|
ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||||
|
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord,
|
||||||
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use time::OffsetDateTime;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
service::{AdminService, LogsQuery, UsageOverviewResponse, UsageRequestQuery, usage_window},
|
service::{
|
||||||
|
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery,
|
||||||
|
usage_window,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
impl AdminService {
|
impl AdminService {
|
||||||
@@ -52,6 +62,76 @@ impl AdminService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn list_approvals(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
query: ApprovalsQuery,
|
||||||
|
) -> Result<Vec<ApprovalRequestRecord>, ApiError> {
|
||||||
|
self.ensure_workspace_exists(workspace_id).await?;
|
||||||
|
let records = self
|
||||||
|
.registry
|
||||||
|
.list_approval_requests(ListApprovalRequestsQuery {
|
||||||
|
workspace_id,
|
||||||
|
status: query.status,
|
||||||
|
limit: query.limit.unwrap_or(50).clamp(1, 200),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut normalized = Vec::with_capacity(records.len());
|
||||||
|
for record in records {
|
||||||
|
let record = self.normalize_approval_record(record).await?;
|
||||||
|
if query.status.is_none() || record.approval.status == query.status.unwrap() {
|
||||||
|
normalized.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn get_approval(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
approval_id: &ApprovalRequestId,
|
||||||
|
) -> Result<ApprovalRequestRecord, ApiError> {
|
||||||
|
self.ensure_workspace_exists(workspace_id).await?;
|
||||||
|
let record = self
|
||||||
|
.registry
|
||||||
|
.get_approval_request(workspace_id, approval_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::not_found_with_context(
|
||||||
|
format!("approval request {} was not found", approval_id.as_str()),
|
||||||
|
json!({ "approval_id": approval_id.as_str() }),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.normalize_approval_record(record).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn normalize_approval_record(
|
||||||
|
&self,
|
||||||
|
record: ApprovalRequestRecord,
|
||||||
|
) -> Result<ApprovalRequestRecord, ApiError> {
|
||||||
|
if record.approval.status != ApprovalRequestStatus::Pending
|
||||||
|
|| record.approval.expires_at > OffsetDateTime::now_utc()
|
||||||
|
{
|
||||||
|
return Ok(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self
|
||||||
|
.registry
|
||||||
|
.expire_approval_request(ExpireApprovalRequest {
|
||||||
|
workspace_id: &record.approval.workspace_id,
|
||||||
|
agent_id: &record.approval.agent_id,
|
||||||
|
approval_id: &record.approval.id,
|
||||||
|
expired_at: OffsetDateTime::now_utc(),
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
.unwrap_or(record))
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_usage_overview(
|
pub async fn get_usage_overview(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
|
|
||||||
|
const MAX_OPERATION_TIMEOUT_MS: u64 = 300_000;
|
||||||
|
|
||||||
pub(super) fn validate_protocol_target(
|
pub(super) fn validate_protocol_target(
|
||||||
protocol: Protocol,
|
protocol: Protocol,
|
||||||
target: &Target,
|
target: &Target,
|
||||||
@@ -16,6 +18,18 @@ pub(super) fn validate_protocol_target(
|
|||||||
Err(ApiError::validation("protocol and target kind must match"))
|
Err(ApiError::validation("protocol and target kind must match"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_execution_timeout(
|
||||||
|
execution_config: &crank_core::ExecutionConfig,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
if !(1..=MAX_OPERATION_TIMEOUT_MS).contains(&execution_config.timeout_ms) {
|
||||||
|
return Err(ApiError::validation_with_context(
|
||||||
|
format!("operation timeout must be between 1 and {MAX_OPERATION_TIMEOUT_MS} ms"),
|
||||||
|
json!({ "field": "execution_config.timeout_ms" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn validate_response_cache_policy(
|
pub(super) fn validate_response_cache_policy(
|
||||||
target: &Target,
|
target: &Target,
|
||||||
execution_config: &crank_core::ExecutionConfig,
|
execution_config: &crank_core::ExecutionConfig,
|
||||||
@@ -125,20 +139,13 @@ pub(super) fn validate_approval_policy(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if policy.confirmation_title.trim().is_empty() {
|
if let Some(message) = policy.elicitation_message.as_ref()
|
||||||
|
&& message.chars().count() > 240
|
||||||
|
{
|
||||||
return Err(ApiError::validation_with_context(
|
return Err(ApiError::validation_with_context(
|
||||||
"approval confirmation title is required".to_owned(),
|
"approval elicitation message must be at most 240 characters".to_owned(),
|
||||||
json!({
|
json!({
|
||||||
"field": "execution_config.approval_policy.confirmation_title",
|
"field": "execution_config.approval_policy.elicitation_message",
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -151,13 +158,14 @@ mod tests {
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy,
|
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, OperationApprovalMode,
|
||||||
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
||||||
ResponseCachePolicy, RestTarget, Target,
|
ResponseCachePolicy, RestTarget, Target,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
validate_approval_policy, validate_idempotency_policy, validate_response_cache_policy,
|
validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
|
||||||
|
validate_response_cache_policy,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn cacheable_execution_config() -> ExecutionConfig {
|
fn cacheable_execution_config() -> ExecutionConfig {
|
||||||
@@ -205,6 +213,19 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_and_excessive_execution_timeouts() {
|
||||||
|
let mut config = cacheable_execution_config();
|
||||||
|
config.timeout_ms = 0;
|
||||||
|
assert!(validate_execution_timeout(&config).is_err());
|
||||||
|
|
||||||
|
config.timeout_ms = 300_001;
|
||||||
|
assert!(validate_execution_timeout(&config).is_err());
|
||||||
|
|
||||||
|
config.timeout_ms = 300_000;
|
||||||
|
assert!(validate_execution_timeout(&config).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_response_cache_for_non_get_rest_operation() {
|
fn rejects_response_cache_for_non_get_rest_operation() {
|
||||||
let target = Target::Rest(RestTarget {
|
let target = Target::Rest(RestTarget {
|
||||||
@@ -290,12 +311,12 @@ mod tests {
|
|||||||
let mut config = cacheable_execution_config();
|
let mut config = cacheable_execution_config();
|
||||||
config.approval_policy = Some(OperationApprovalPolicy {
|
config.approval_policy = Some(OperationApprovalPolicy {
|
||||||
required: true,
|
required: true,
|
||||||
|
mode: OperationApprovalMode::Custom,
|
||||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||||
confirmation_title: "Подтвердите действие".to_owned(),
|
|
||||||
confirmation_body_template: "Выполнить действие?".to_owned(),
|
|
||||||
ttl_seconds: 300,
|
ttl_seconds: 300,
|
||||||
show_payload_preview: true,
|
show_payload_preview: true,
|
||||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||||
|
elicitation_message: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
validate_approval_policy(&config).unwrap();
|
validate_approval_policy(&config).unwrap();
|
||||||
@@ -306,12 +327,12 @@ mod tests {
|
|||||||
let mut config = cacheable_execution_config();
|
let mut config = cacheable_execution_config();
|
||||||
config.approval_policy = Some(OperationApprovalPolicy {
|
config.approval_policy = Some(OperationApprovalPolicy {
|
||||||
required: true,
|
required: true,
|
||||||
|
mode: OperationApprovalMode::Custom,
|
||||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||||
confirmation_title: "".to_owned(),
|
|
||||||
confirmation_body_template: "Выполнить действие?".to_owned(),
|
|
||||||
ttl_seconds: 0,
|
ttl_seconds: 0,
|
||||||
show_payload_preview: true,
|
show_payload_preview: true,
|
||||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||||
|
elicitation_message: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let error = validate_approval_policy(&config).unwrap_err();
|
let error = validate_approval_policy(&config).unwrap_err();
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ use crank_runtime::RequestRateLimiter;
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub service: AdminService,
|
pub service: AdminService,
|
||||||
pub api_rate_limiter: RequestRateLimiter,
|
pub api_rate_limiter: RequestRateLimiter,
|
||||||
|
/// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification.
|
||||||
|
///
|
||||||
|
/// Only enable when the service sits behind a trusted reverse proxy that
|
||||||
|
/// overwrites these headers (e.g. the bundled nginx). When disabled the
|
||||||
|
/// real TCP peer address is used, which a client cannot spoof.
|
||||||
|
pub trust_forwarded_headers: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,15 +109,19 @@ async fn rejects_rapid_login_requests_with_429() {
|
|||||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||||
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
|
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||||
),
|
),
|
||||||
|
trust_forwarded_headers: false,
|
||||||
});
|
});
|
||||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
axum::serve(listener, app)
|
axum::serve(
|
||||||
.with_graceful_shutdown(async move {
|
listener,
|
||||||
let _ = shutdown_rx.await;
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
})
|
)
|
||||||
.await
|
.with_graceful_shutdown(async move {
|
||||||
.unwrap();
|
let _ = shutdown_rx.await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ pub(super) fn build_test_app(
|
|||||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||||
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||||
),
|
),
|
||||||
|
trust_forwarded_headers: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,13 +114,16 @@ pub(super) fn test_service(
|
|||||||
auth_settings: AuthSettings,
|
auth_settings: AuthSettings,
|
||||||
secret_crypto: SecretCrypto,
|
secret_crypto: SecretCrypto,
|
||||||
) -> AdminService {
|
) -> AdminService {
|
||||||
|
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
|
||||||
|
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
|
||||||
AdminServiceBuilder::new(
|
AdminServiceBuilder::new(
|
||||||
registry,
|
registry,
|
||||||
storage_root,
|
storage_root,
|
||||||
auth_settings,
|
auth_settings,
|
||||||
secret_crypto,
|
secret_crypto,
|
||||||
crank_runtime::RuntimeExecutor::new(),
|
runtime,
|
||||||
)
|
)
|
||||||
|
.with_outbound_http_policy(outbound_policy)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.85-bookworm AS deps
|
FROM rust:1.96.1-bookworm AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
--mount=type=cache,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
SQLX_OFFLINE=true cargo build --release -p mcp-server
|
SQLX_OFFLINE=true cargo build --release -p mcp-server
|
||||||
|
|
||||||
FROM rust:1.85-bookworm AS builder
|
FROM rust:1.96.1-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use std::{env, net::SocketAddr, time::Duration};
|
use std::{env, net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
use crank_community_mcp::{
|
use crank_community_mcp::{
|
||||||
auth::CommunityMachineCredentialVerifier, build_app, session::PostgresTransportSessionStore,
|
auth::CommunityMachineCredentialVerifier, build_app_with_background_workers,
|
||||||
|
session::PostgresTransportSessionStore,
|
||||||
};
|
};
|
||||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||||
use crank_runtime::{
|
use crank_runtime::{
|
||||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||||
RuntimeLimits, SecretCrypto, community_default,
|
RuntimeLimits, SecretCrypto,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgConnectOptions;
|
use sqlx::postgres::PgConnectOptions;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -46,12 +47,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||||
let runtime = community_default()
|
let runtime = crank_runtime::community_from_env()?
|
||||||
.with_limits(runtime_limits)
|
.with_limits(runtime_limits)
|
||||||
.with_response_cache(cache_stores.response.clone())
|
.with_response_cache(cache_stores.response.clone())
|
||||||
.with_coordination_store(cache_stores.coordination.clone())
|
.with_coordination_store(cache_stores.coordination.clone())
|
||||||
.build();
|
.build();
|
||||||
let app = build_app(
|
let app = build_app_with_background_workers(
|
||||||
registry,
|
registry,
|
||||||
refresh_interval,
|
refresh_interval,
|
||||||
base_url,
|
base_url,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#![allow(dead_code, unused_imports)]
|
#![allow(dead_code, unused_imports)]
|
||||||
|
|
||||||
|
mod approval_access;
|
||||||
|
|
||||||
use super::common::*;
|
use super::common::*;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
@@ -18,10 +20,11 @@ use axum::{
|
|||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
|
||||||
ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, Operation,
|
ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, InvocationSource,
|
||||||
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
Operation, OperationApprovalMode, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy,
|
||||||
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||||
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
|
||||||
|
WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
@@ -139,7 +142,10 @@ fn build_test_app_with_store(
|
|||||||
refresh_interval,
|
refresh_interval,
|
||||||
public_base_url,
|
public_base_url,
|
||||||
SecretCrypto::new("test-master-key").unwrap(),
|
SecretCrypto::new("test-master-key").unwrap(),
|
||||||
RuntimeExecutor::new(),
|
crank_runtime::community_with_outbound_policy(
|
||||||
|
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
RequestRateLimiter::new(rate_limit_config),
|
RequestRateLimiter::new(rate_limit_config),
|
||||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||||
sessions,
|
sessions,
|
||||||
@@ -511,216 +517,6 @@ async fn rejects_initialize_with_approval_platform_api_key() {
|
|||||||
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn approval_key_lists_and_decides_pending_requests() {
|
|
||||||
let registry = test_registry().await;
|
|
||||||
let upstream_base_url = spawn_upstream_server().await;
|
|
||||||
let operation = test_operation(&upstream_base_url, "crm_human_approval");
|
|
||||||
registry
|
|
||||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
publish_agent_with_bindings(
|
|
||||||
®istry,
|
|
||||||
"sales-human-approval",
|
|
||||||
vec![binding_for_operation(&operation)],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let approval = ApprovalRequest {
|
|
||||||
id: ApprovalRequestId::new("approval_mcp_01"),
|
|
||||||
workspace_id: test_workspace_id(),
|
|
||||||
agent_id: test_agent_id("sales-human-approval"),
|
|
||||||
operation_id: operation.id.clone(),
|
|
||||||
operation_version: 1,
|
|
||||||
status: ApprovalRequestStatus::Pending,
|
|
||||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
|
||||||
confirmation_title: "Подтвердите создание лида".to_owned(),
|
|
||||||
confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(),
|
|
||||||
request_payload: json!({"email": "ada@example.com"}),
|
|
||||||
response_payload: None,
|
|
||||||
created_at: OffsetDateTime::now_utc(),
|
|
||||||
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
|
|
||||||
decided_at: None,
|
|
||||||
decided_by_key_id: None,
|
|
||||||
decision_note: None,
|
|
||||||
};
|
|
||||||
registry
|
|
||||||
.create_approval_request(CreateApprovalRequest {
|
|
||||||
approval: &approval,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let approval_key =
|
|
||||||
create_approval_platform_api_key(®istry, "sales-human-approval", "approval-http").await;
|
|
||||||
let mcp_key = create_platform_api_key(
|
|
||||||
®istry,
|
|
||||||
"sales-human-approval",
|
|
||||||
"mcp-human-approval",
|
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let base_url = spawn_mcp_server(build_test_app(
|
|
||||||
registry.clone(),
|
|
||||||
Duration::from_millis(0),
|
|
||||||
Some("https://crank.example.com".to_owned()),
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let approvals_url = format!(
|
|
||||||
"{}/approvals",
|
|
||||||
agent_mcp_url(&base_url, "sales-human-approval")
|
|
||||||
);
|
|
||||||
|
|
||||||
let rejected = client
|
|
||||||
.get(&approvals_url)
|
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {mcp_key}"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED);
|
|
||||||
|
|
||||||
let pending = client
|
|
||||||
.get(&approvals_url)
|
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(pending.status(), reqwest::StatusCode::OK);
|
|
||||||
let pending_body = pending.json::<Value>().await.unwrap();
|
|
||||||
assert_eq!(pending_body["items"].as_array().unwrap().len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
pending_body["items"][0]["approval"]["confirmation_title"],
|
|
||||||
"Подтвердите создание лида"
|
|
||||||
);
|
|
||||||
|
|
||||||
let approve_url = format!(
|
|
||||||
"{}/approvals/{}/approve",
|
|
||||||
agent_mcp_url(&base_url, "sales-human-approval"),
|
|
||||||
approval.id
|
|
||||||
);
|
|
||||||
let approved = client
|
|
||||||
.post(&approve_url)
|
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
|
||||||
.json(&json!({ "approve": "yes", "note": "confirmed by test" }))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(approved.status(), reqwest::StatusCode::OK);
|
|
||||||
let approved_body = approved.json::<Value>().await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
approved_body["approval"]["status"],
|
|
||||||
Value::String("approved".to_owned())
|
|
||||||
);
|
|
||||||
|
|
||||||
let pending_after = client
|
|
||||||
.get(&approvals_url)
|
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json::<Value>()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(pending_after["items"].as_array().unwrap().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn tool_call_with_approval_policy_creates_pending_request() {
|
|
||||||
let registry = test_registry().await;
|
|
||||||
let upstream_base_url = spawn_upstream_server().await;
|
|
||||||
let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval");
|
|
||||||
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
|
||||||
required: true,
|
|
||||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
|
||||||
confirmation_title: "Подтвердите создание лида".to_owned(),
|
|
||||||
confirmation_body_template: "Проверьте email перед отправкой в CRM.".to_owned(),
|
|
||||||
ttl_seconds: 300,
|
|
||||||
show_payload_preview: true,
|
|
||||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
|
||||||
});
|
|
||||||
registry
|
|
||||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
registry
|
|
||||||
.publish_operation(PublishRequest {
|
|
||||||
workspace_id: &test_workspace_id(),
|
|
||||||
operation_id: &operation.id,
|
|
||||||
version: 1,
|
|
||||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
|
||||||
published_by: Some("alice"),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
publish_agent_for_operation(®istry, &operation, "sales-gated").await;
|
|
||||||
let api_key = create_platform_api_key(
|
|
||||||
®istry,
|
|
||||||
"sales-gated",
|
|
||||||
"mcp-gated",
|
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let approval_key =
|
|
||||||
create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await;
|
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_test_app(
|
|
||||||
registry,
|
|
||||||
Duration::from_millis(0),
|
|
||||||
Some("https://crank.example.com".to_owned()),
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let mcp_url = agent_mcp_url(&base_url, "sales-gated");
|
|
||||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
|
||||||
|
|
||||||
let tool_result = post_jsonrpc(
|
|
||||||
&client,
|
|
||||||
&mcp_url,
|
|
||||||
&api_key,
|
|
||||||
Some(&initialized_session),
|
|
||||||
json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 9,
|
|
||||||
"method": "tools/call",
|
|
||||||
"params": {
|
|
||||||
"name": "crm_requires_human_approval",
|
|
||||||
"arguments": {
|
|
||||||
"email": "ada@example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
tool_result["result"]["structuredContent"]["status"],
|
|
||||||
"approval_required"
|
|
||||||
);
|
|
||||||
assert_eq!(tool_result["result"]["isError"], false);
|
|
||||||
let approval_id = tool_result["result"]["structuredContent"]["approval_id"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap();
|
|
||||||
assert!(approval_id.starts_with("approval_"));
|
|
||||||
|
|
||||||
let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated"));
|
|
||||||
let pending = client
|
|
||||||
.get(&approvals_url)
|
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json::<Value>()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(pending["items"].as_array().unwrap().len(), 1);
|
|
||||||
assert_eq!(pending["items"][0]["approval"]["id"], approval_id);
|
|
||||||
assert_eq!(
|
|
||||||
pending["items"][0]["approval"]["request_payload"]["email"],
|
|
||||||
"ada@example.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rejects_tool_call_with_read_only_platform_api_key() {
|
async fn rejects_tool_call_with_read_only_platform_api_key() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approval_key_lists_and_decides_pending_requests() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let operation = test_operation(&upstream_base_url, "crm_human_approval");
|
||||||
|
registry
|
||||||
|
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
publish_agent_with_bindings(
|
||||||
|
®istry,
|
||||||
|
"sales-human-approval",
|
||||||
|
vec![binding_for_operation(&operation)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let approval = ApprovalRequest {
|
||||||
|
id: ApprovalRequestId::new("approval_mcp_01"),
|
||||||
|
workspace_id: test_workspace_id(),
|
||||||
|
agent_id: test_agent_id("sales-human-approval"),
|
||||||
|
operation_id: operation.id.clone(),
|
||||||
|
operation_version: 1,
|
||||||
|
status: ApprovalRequestStatus::Pending,
|
||||||
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||||
|
request_payload: json!({"email": "ada@example.com"}),
|
||||||
|
response_payload: None,
|
||||||
|
created_at: OffsetDateTime::now_utc(),
|
||||||
|
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
|
||||||
|
decided_at: None,
|
||||||
|
decided_by_key_id: None,
|
||||||
|
decision_note: None,
|
||||||
|
};
|
||||||
|
registry
|
||||||
|
.create_approval_request(CreateApprovalRequest {
|
||||||
|
approval: &approval,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let approval_key =
|
||||||
|
create_approval_platform_api_key(®istry, "sales-human-approval", "approval-http").await;
|
||||||
|
let mcp_key = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"sales-human-approval",
|
||||||
|
"mcp-human-approval",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
|
registry.clone(),
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let approvals_url = format!(
|
||||||
|
"{}/approvals",
|
||||||
|
agent_mcp_url(&base_url, "sales-human-approval")
|
||||||
|
);
|
||||||
|
|
||||||
|
let rejected = client
|
||||||
|
.get(&approvals_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {mcp_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let pending = client
|
||||||
|
.get(&approvals_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending.status(), reqwest::StatusCode::OK);
|
||||||
|
let pending_body = pending.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(pending_body["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
pending_body["items"][0]["approval"]["request_payload"],
|
||||||
|
json!({"email": "ada@example.com"})
|
||||||
|
);
|
||||||
|
|
||||||
|
let approve_url = format!(
|
||||||
|
"{}/approvals/{}/approve",
|
||||||
|
agent_mcp_url(&base_url, "sales-human-approval"),
|
||||||
|
approval.id
|
||||||
|
);
|
||||||
|
let approved = client
|
||||||
|
.post(&approve_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.json(&json!({ "approve": "yes", "note": "confirmed by test" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(approved.status(), reqwest::StatusCode::OK);
|
||||||
|
let approved_body = approved.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
approved_body["approval"]["status"],
|
||||||
|
Value::String("completed".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
approved_body["approval"]["response_payload"],
|
||||||
|
json!({ "id": "lead_123" })
|
||||||
|
);
|
||||||
|
|
||||||
|
let status_url = format!(
|
||||||
|
"{}/approvals/{}",
|
||||||
|
agent_mcp_url(&base_url, "sales-human-approval"),
|
||||||
|
approval.id
|
||||||
|
);
|
||||||
|
let current = client
|
||||||
|
.get(&status_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(current.status(), reqwest::StatusCode::OK);
|
||||||
|
let current_body = current.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
current_body["approval"]["status"],
|
||||||
|
Value::String("completed".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
current_body["approval"]["response_payload"],
|
||||||
|
json!({ "id": "lead_123" })
|
||||||
|
);
|
||||||
|
|
||||||
|
let repeated_approve = client
|
||||||
|
.post(&approve_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.json(&json!({ "approve": "yes", "note": "duplicate confirmation" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(repeated_approve.status(), reqwest::StatusCode::OK);
|
||||||
|
let repeated_body = repeated_approve.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
repeated_body["approval"]["status"],
|
||||||
|
Value::String("completed".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
repeated_body["approval"]["response_payload"],
|
||||||
|
json!({ "id": "lead_123" })
|
||||||
|
);
|
||||||
|
|
||||||
|
let pending_after = client
|
||||||
|
.get(&approvals_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(pending_after["items"].as_array().unwrap().is_empty());
|
||||||
|
|
||||||
|
let logs = registry
|
||||||
|
.list_invocation_logs(ListInvocationLogsQuery {
|
||||||
|
workspace_id: &test_workspace_id(),
|
||||||
|
level: None,
|
||||||
|
search_text: None,
|
||||||
|
source: Some(InvocationSource::AgentToolCall),
|
||||||
|
operation_id: Some(&operation.id),
|
||||||
|
agent_id: Some(&test_agent_id("sales-human-approval")),
|
||||||
|
created_after: None,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(logs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approval_key_denies_without_executing_upstream() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let operation = test_operation(&upstream_base_url, "crm_human_deny");
|
||||||
|
registry
|
||||||
|
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
publish_agent_with_bindings(
|
||||||
|
®istry,
|
||||||
|
"sales-human-deny",
|
||||||
|
vec![binding_for_operation(&operation)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let approval = ApprovalRequest {
|
||||||
|
id: ApprovalRequestId::new("approval_mcp_deny_01"),
|
||||||
|
workspace_id: test_workspace_id(),
|
||||||
|
agent_id: test_agent_id("sales-human-deny"),
|
||||||
|
operation_id: operation.id.clone(),
|
||||||
|
operation_version: 1,
|
||||||
|
status: ApprovalRequestStatus::Pending,
|
||||||
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||||
|
request_payload: json!({"email": "deny@example.com"}),
|
||||||
|
response_payload: None,
|
||||||
|
created_at: OffsetDateTime::now_utc(),
|
||||||
|
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
|
||||||
|
decided_at: None,
|
||||||
|
decided_by_key_id: None,
|
||||||
|
decision_note: None,
|
||||||
|
};
|
||||||
|
registry
|
||||||
|
.create_approval_request(CreateApprovalRequest {
|
||||||
|
approval: &approval,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let approval_key =
|
||||||
|
create_approval_platform_api_key(®istry, "sales-human-deny", "approval-deny-http").await;
|
||||||
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
|
registry.clone(),
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let deny_url = format!(
|
||||||
|
"{}/approvals/{}/deny",
|
||||||
|
agent_mcp_url(&base_url, "sales-human-deny"),
|
||||||
|
approval.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let denied = client
|
||||||
|
.post(&deny_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.json(&json!({ "approve": "no", "note": "rejected by test" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(denied.status(), reqwest::StatusCode::OK);
|
||||||
|
let denied_body = denied.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
denied_body["approval"]["status"],
|
||||||
|
Value::String("denied".to_owned())
|
||||||
|
);
|
||||||
|
|
||||||
|
let repeated_deny = client
|
||||||
|
.post(&deny_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.json(&json!({ "approve": "no", "note": "duplicate rejection" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(repeated_deny.status(), reqwest::StatusCode::OK);
|
||||||
|
let repeated_body = repeated_deny.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
repeated_body["approval"]["status"],
|
||||||
|
Value::String("denied".to_owned())
|
||||||
|
);
|
||||||
|
|
||||||
|
let logs = registry
|
||||||
|
.list_invocation_logs(ListInvocationLogsQuery {
|
||||||
|
workspace_id: &test_workspace_id(),
|
||||||
|
level: None,
|
||||||
|
search_text: None,
|
||||||
|
source: Some(InvocationSource::AgentToolCall),
|
||||||
|
operation_id: Some(&operation.id),
|
||||||
|
agent_id: Some(&test_agent_id("sales-human-deny")),
|
||||||
|
created_after: None,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(logs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approval_key_expires_without_executing_upstream() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let operation = test_operation(&upstream_base_url, "crm_human_expired");
|
||||||
|
registry
|
||||||
|
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
publish_agent_with_bindings(
|
||||||
|
®istry,
|
||||||
|
"sales-human-expired",
|
||||||
|
vec![binding_for_operation(&operation)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let approval = ApprovalRequest {
|
||||||
|
id: ApprovalRequestId::new("approval_mcp_expired_01"),
|
||||||
|
workspace_id: test_workspace_id(),
|
||||||
|
agent_id: test_agent_id("sales-human-expired"),
|
||||||
|
operation_id: operation.id.clone(),
|
||||||
|
operation_version: 1,
|
||||||
|
status: ApprovalRequestStatus::Pending,
|
||||||
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||||
|
request_payload: json!({"email": "expired@example.com"}),
|
||||||
|
response_payload: None,
|
||||||
|
created_at: OffsetDateTime::now_utc() - time::Duration::minutes(10),
|
||||||
|
expires_at: OffsetDateTime::now_utc() - time::Duration::minutes(5),
|
||||||
|
decided_at: None,
|
||||||
|
decided_by_key_id: None,
|
||||||
|
decision_note: None,
|
||||||
|
};
|
||||||
|
registry
|
||||||
|
.create_approval_request(CreateApprovalRequest {
|
||||||
|
approval: &approval,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let approval_key =
|
||||||
|
create_approval_platform_api_key(®istry, "sales-human-expired", "approval-expired-http")
|
||||||
|
.await;
|
||||||
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
|
registry.clone(),
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let approve_url = format!(
|
||||||
|
"{}/approvals/{}/approve",
|
||||||
|
agent_mcp_url(&base_url, "sales-human-expired"),
|
||||||
|
approval.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let expired = client
|
||||||
|
.post(&approve_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.json(&json!({ "approve": "yes", "note": "too late" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(expired.status(), reqwest::StatusCode::OK);
|
||||||
|
let expired_body = expired.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
expired_body["approval"]["status"],
|
||||||
|
Value::String("expired".to_owned())
|
||||||
|
);
|
||||||
|
|
||||||
|
let logs = registry
|
||||||
|
.list_invocation_logs(ListInvocationLogsQuery {
|
||||||
|
workspace_id: &test_workspace_id(),
|
||||||
|
level: None,
|
||||||
|
search_text: None,
|
||||||
|
source: Some(InvocationSource::AgentToolCall),
|
||||||
|
operation_id: Some(&operation.id),
|
||||||
|
agent_id: Some(&test_agent_id("sales-human-expired")),
|
||||||
|
created_after: None,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(logs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_call_with_approval_policy_creates_pending_request() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval");
|
||||||
|
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||||
|
required: true,
|
||||||
|
mode: OperationApprovalMode::Custom,
|
||||||
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||||
|
ttl_seconds: 300,
|
||||||
|
show_payload_preview: true,
|
||||||
|
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||||
|
elicitation_message: None,
|
||||||
|
});
|
||||||
|
registry
|
||||||
|
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.publish_operation(PublishRequest {
|
||||||
|
workspace_id: &test_workspace_id(),
|
||||||
|
operation_id: &operation.id,
|
||||||
|
version: 1,
|
||||||
|
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||||
|
published_by: Some("alice"),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
publish_agent_for_operation(®istry, &operation, "sales-gated").await;
|
||||||
|
let api_key = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"sales-gated",
|
||||||
|
"mcp-gated",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let approval_key =
|
||||||
|
create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await;
|
||||||
|
|
||||||
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
|
registry,
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mcp_url = agent_mcp_url(&base_url, "sales-gated");
|
||||||
|
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||||
|
|
||||||
|
let tool_result = post_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&mcp_url,
|
||||||
|
&api_key,
|
||||||
|
Some(&initialized_session),
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 9,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "crm_requires_human_approval",
|
||||||
|
"arguments": {
|
||||||
|
"email": "ada@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tool_result["result"]["structuredContent"]["status"],
|
||||||
|
"approval_required"
|
||||||
|
);
|
||||||
|
assert_eq!(tool_result["result"]["isError"], false);
|
||||||
|
let approval_id = tool_result["result"]["structuredContent"]["approval_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap();
|
||||||
|
assert!(approval_id.starts_with("approval_"));
|
||||||
|
|
||||||
|
let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated"));
|
||||||
|
let pending = client
|
||||||
|
.get(&approvals_url)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(pending["items"][0]["approval"]["id"], approval_id);
|
||||||
|
assert_eq!(
|
||||||
|
pending["items"][0]["approval"]["request_payload"]["email"],
|
||||||
|
"ada@example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn elicitation_approval_requires_client_capability() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation");
|
||||||
|
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||||
|
required: true,
|
||||||
|
mode: OperationApprovalMode::Elicitation,
|
||||||
|
risk_level: OperationApprovalRiskLevel::Normal,
|
||||||
|
ttl_seconds: 300,
|
||||||
|
show_payload_preview: true,
|
||||||
|
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||||
|
elicitation_message: Some("Подтвердите создание лида.".to_owned()),
|
||||||
|
});
|
||||||
|
registry
|
||||||
|
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.publish_operation(PublishRequest {
|
||||||
|
workspace_id: &test_workspace_id(),
|
||||||
|
operation_id: &operation.id,
|
||||||
|
version: 1,
|
||||||
|
published_at: &OffsetDateTime::now_utc(),
|
||||||
|
published_by: Some("alice"),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
publish_agent_with_bindings(
|
||||||
|
®istry,
|
||||||
|
"sales-elicitation-no-capability",
|
||||||
|
vec![binding_for_operation(&operation)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let api_key = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"sales-elicitation-no-capability",
|
||||||
|
"mcp-elicitation-no-capability",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
|
registry.clone(),
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mcp_url = agent_mcp_url(&base_url, "sales-elicitation-no-capability");
|
||||||
|
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||||
|
|
||||||
|
let tool_result = post_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&mcp_url,
|
||||||
|
&api_key,
|
||||||
|
Some(&initialized_session),
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 7,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "crm_requires_elicitation",
|
||||||
|
"arguments": {
|
||||||
|
"email": "ada@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(tool_result["result"]["isError"], true);
|
||||||
|
assert_eq!(
|
||||||
|
tool_result["result"]["structuredContent"]["error"]["code"],
|
||||||
|
"approval_elicitation_not_supported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn elicitation_approval_uses_session_capability_without_approval_key() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation_supported");
|
||||||
|
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||||
|
required: true,
|
||||||
|
mode: OperationApprovalMode::Elicitation,
|
||||||
|
risk_level: OperationApprovalRiskLevel::Normal,
|
||||||
|
ttl_seconds: 300,
|
||||||
|
show_payload_preview: true,
|
||||||
|
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||||
|
elicitation_message: Some("Подтвердите создание лида.".to_owned()),
|
||||||
|
});
|
||||||
|
registry
|
||||||
|
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.publish_operation(PublishRequest {
|
||||||
|
workspace_id: &test_workspace_id(),
|
||||||
|
operation_id: &operation.id,
|
||||||
|
version: 1,
|
||||||
|
published_at: &OffsetDateTime::now_utc(),
|
||||||
|
published_by: Some("alice"),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
publish_agent_with_bindings(
|
||||||
|
®istry,
|
||||||
|
"sales-elicitation-supported",
|
||||||
|
vec![binding_for_operation(&operation)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let api_key = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"sales-elicitation-supported",
|
||||||
|
"mcp-elicitation-supported",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
|
registry.clone(),
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mcp_url = agent_mcp_url(&base_url, "sales-elicitation-supported");
|
||||||
|
let initialized_session = initialize_session_with_capabilities(
|
||||||
|
&client,
|
||||||
|
&mcp_url,
|
||||||
|
&api_key,
|
||||||
|
json!({ "elicitation": {} }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tool_result = post_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&mcp_url,
|
||||||
|
&api_key,
|
||||||
|
Some(&initialized_session),
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 8,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "crm_requires_elicitation_supported",
|
||||||
|
"arguments": {
|
||||||
|
"email": "ada@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(tool_result["result"]["isError"], false);
|
||||||
|
assert_eq!(
|
||||||
|
tool_result["result"]["structuredContent"]["status"],
|
||||||
|
"elicitation_required"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tool_result["result"]["structuredContent"]["message"],
|
||||||
|
"Подтвердите создание лида."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tool_result["result"]["structuredContent"]["payload_preview"]["email"],
|
||||||
|
"ada@example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -135,7 +135,10 @@ fn build_test_app_with_store(
|
|||||||
refresh_interval,
|
refresh_interval,
|
||||||
public_base_url,
|
public_base_url,
|
||||||
SecretCrypto::new("test-master-key").unwrap(),
|
SecretCrypto::new("test-master-key").unwrap(),
|
||||||
RuntimeExecutor::new(),
|
crank_runtime::community_with_outbound_policy(
|
||||||
|
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
RequestRateLimiter::new(rate_limit_config),
|
RequestRateLimiter::new(rate_limit_config),
|
||||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||||
sessions,
|
sessions,
|
||||||
@@ -147,6 +150,15 @@ pub(super) async fn initialize_session(
|
|||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
mcp_url: &str,
|
mcp_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
|
) -> String {
|
||||||
|
initialize_session_with_capabilities(client, mcp_url, api_key, json!({})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn initialize_session_with_capabilities(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
mcp_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
capabilities: Value,
|
||||||
) -> String {
|
) -> String {
|
||||||
let initialize_response = client
|
let initialize_response = client
|
||||||
.post(mcp_url)
|
.post(mcp_url)
|
||||||
@@ -157,7 +169,8 @@ pub(super) async fn initialize_session(
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"method": "initialize",
|
"method": "initialize",
|
||||||
"params": {
|
"params": {
|
||||||
"protocolVersion": "2025-11-25"
|
"protocolVersion": "2025-11-25",
|
||||||
|
"capabilities": capabilities
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
|
|||||||
@@ -136,7 +136,10 @@ fn build_test_app_with_store(
|
|||||||
refresh_interval,
|
refresh_interval,
|
||||||
public_base_url,
|
public_base_url,
|
||||||
SecretCrypto::new("test-master-key").unwrap(),
|
SecretCrypto::new("test-master-key").unwrap(),
|
||||||
RuntimeExecutor::new(),
|
crank_runtime::community_with_outbound_policy(
|
||||||
|
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
RequestRateLimiter::new(rate_limit_config),
|
RequestRateLimiter::new(rate_limit_config),
|
||||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||||
sessions,
|
sessions,
|
||||||
@@ -689,6 +692,7 @@ async fn get_returns_not_found_for_expired_transport_session() {
|
|||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
test_workspace_slug(),
|
test_workspace_slug(),
|
||||||
"sales-expired-session",
|
"sales-expired-session",
|
||||||
|
false,
|
||||||
OffsetDateTime::parse("2026-05-01T10:00:00Z", &Rfc3339).unwrap(),
|
OffsetDateTime::parse("2026-05-01T10:00:00Z", &Rfc3339).unwrap(),
|
||||||
Some(OffsetDateTime::parse("2026-05-01T10:00:01Z", &Rfc3339).unwrap()),
|
Some(OffsetDateTime::parse("2026-05-01T10:00:01Z", &Rfc3339).unwrap()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/* Local font assets are copied from pinned @fontsource packages during the UI build. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('../fonts/inter-cyrillic-400-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('../fonts/inter-latin-400-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('../fonts/inter-cyrillic-500-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('../fonts/inter-latin-500-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 600;
|
||||||
|
src: url('../fonts/inter-cyrillic-600-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 600;
|
||||||
|
src: url('../fonts/inter-latin-600-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('../fonts/inter-cyrillic-700-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('../fonts/inter-latin-700-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('../fonts/jetbrains-mono-cyrillic-400-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('../fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('../fonts/jetbrains-mono-cyrillic-500-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('../fonts/jetbrains-mono-latin-500-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
@@ -246,7 +246,7 @@ body.page-leaving .ws-setup-body {
|
|||||||
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
|
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
/* ── Responsive breakpoints ── */
|
/* ── Responsive breakpoints ── */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 980px) {
|
||||||
.navbar { padding: 0 16px; }
|
.navbar { padding: 0 16px; }
|
||||||
.nav-links { display: none; }
|
.nav-links { display: none; }
|
||||||
.nav-hamburger { display: flex; }
|
.nav-hamburger { display: flex; }
|
||||||
|
|||||||
@@ -134,3 +134,188 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
|
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
|
||||||
|
|
||||||
|
.approval-panel {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-panel-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-panel-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-refresh-btn {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-empty {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-canvas);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-empty-error {
|
||||||
|
border-color: rgba(248, 81, 73, 0.35);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-canvas);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-pending {
|
||||||
|
border-color: rgba(210, 153, 34, 0.45);
|
||||||
|
background: linear-gradient(180deg, rgba(210, 153, 34, 0.08), var(--bg-canvas) 46%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-completed {
|
||||||
|
border-color: rgba(63, 185, 80, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-failed,
|
||||||
|
.approval-denied,
|
||||||
|
.approval-expired {
|
||||||
|
border-color: rgba(248, 81, 73, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-item-meta,
|
||||||
|
.approval-timing,
|
||||||
|
.approval-note {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-item-body {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.35px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-status-pending {
|
||||||
|
color: var(--amber);
|
||||||
|
border-color: rgba(210, 153, 34, 0.45);
|
||||||
|
background: rgba(210, 153, 34, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-status-completed {
|
||||||
|
color: var(--green);
|
||||||
|
border-color: rgba(63, 185, 80, 0.35);
|
||||||
|
background: rgba(63, 185, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-status-denied,
|
||||||
|
.approval-status-expired,
|
||||||
|
.approval-status-failed {
|
||||||
|
color: var(--red);
|
||||||
|
border-color: rgba(248, 81, 73, 0.35);
|
||||||
|
background: rgba(248, 81, 73, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-payload-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-payload-label {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.45px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-payload-code {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #161b22;
|
||||||
|
padding: 10px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.approval-panel-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-refresh-btn {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-item-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-status {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+25
-16
@@ -12,23 +12,26 @@
|
|||||||
.openapi-import-modal {
|
.openapi-import-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1200;
|
z-index: 2400;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 28px 16px;
|
padding: 28px 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-backdrop {
|
.openapi-import-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(4, 8, 18, 0.72);
|
z-index: 0;
|
||||||
backdrop-filter: blur(3px);
|
background: rgba(1, 4, 9, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-dialog {
|
.openapi-import-dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
width: min(1040px, calc(100vw - 32px));
|
width: min(1040px, calc(100vw - 32px));
|
||||||
max-height: calc(100vh - 56px);
|
max-height: calc(100vh - 56px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -37,8 +40,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: var(--surface);
|
background: var(--bg-canvas);
|
||||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-header {
|
.openapi-import-header {
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-header h2 {
|
.openapi-import-header h2 {
|
||||||
@@ -63,11 +67,16 @@
|
|||||||
.openapi-import-body {
|
.openapi-import-body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 20px 24px 24px;
|
padding: 20px 24px 24px;
|
||||||
|
background: var(--bg-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-upload {
|
.openapi-import-upload {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-file-label {
|
.openapi-file-label {
|
||||||
@@ -85,7 +94,7 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--surface-muted);
|
background: #0d1117;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -155,7 +164,7 @@
|
|||||||
.openapi-import-group {
|
.openapi-import-group {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: var(--surface-muted);
|
background: var(--bg-overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-source {
|
.openapi-import-source {
|
||||||
@@ -184,7 +193,7 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--surface);
|
background: var(--bg-surface);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +205,7 @@
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: var(--surface-muted);
|
background: var(--bg-overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-filter {
|
.openapi-import-filter {
|
||||||
@@ -214,7 +223,7 @@
|
|||||||
#openapi-import-method-filter {
|
#openapi-import-method-filter {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
background: var(--surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-bulk-actions {
|
.openapi-import-bulk-actions {
|
||||||
@@ -282,7 +291,7 @@
|
|||||||
.openapi-import-method {
|
.openapi-import-method {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent-muted);
|
background: var(--accent-glow);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -322,7 +331,7 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-mapping-group {
|
.openapi-import-mapping-group {
|
||||||
@@ -344,7 +353,7 @@
|
|||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--surface-muted);
|
background: var(--bg-overlay);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -362,7 +371,7 @@
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: var(--surface-muted);
|
background: var(--bg-overlay);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -387,7 +396,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-result-row {
|
.openapi-import-result-row {
|
||||||
@@ -409,7 +418,7 @@
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
background: var(--surface-muted);
|
background: var(--bg-overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-import-result-name {
|
.openapi-import-result-name {
|
||||||
|
|||||||
+185
-33
@@ -202,6 +202,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
minmax(130px, 1fr)
|
minmax(130px, 1fr)
|
||||||
|
24px
|
||||||
minmax(105px, 0.65fr)
|
minmax(105px, 0.65fr)
|
||||||
minmax(130px, 1fr)
|
minmax(130px, 1fr)
|
||||||
minmax(120px, 0.8fr)
|
minmax(120px, 0.8fr)
|
||||||
@@ -216,14 +217,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.response-mapping-row {
|
.response-mapping-row {
|
||||||
grid-template-columns: minmax(160px, 1fr) minmax(140px, 1fr) 34px;
|
grid-template-columns: minmax(160px, 1fr) 24px minmax(140px, 1fr) 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-field-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-arrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: end;
|
||||||
|
width: 24px;
|
||||||
|
height: 34px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-default-value {
|
||||||
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapping-row-remove {
|
.mapping-row-remove {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--red-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--red-bg);
|
||||||
|
color: var(--red);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-row-remove:hover {
|
||||||
|
border-color: rgba(248, 81, 73, 0.45);
|
||||||
|
background: rgba(248, 81, 73, 0.16);
|
||||||
|
color: #ff7b72;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapping-builder-actions {
|
.mapping-builder-actions {
|
||||||
@@ -315,6 +364,12 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapping-arrow {
|
||||||
|
width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
.mapping-row-remove {
|
.mapping-row-remove {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -412,7 +467,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: background 0.15s;
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,37 +1142,6 @@
|
|||||||
justify-content: center;
|
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
|
||||||
══════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════ */
|
||||||
@@ -1306,6 +1334,18 @@
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group > .code-textarea {
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
background: #0d1117 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group > .code-textarea:focus {
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-ring), inset 0 0 0 1px rgba(255, 255, 255, 0.02) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Section divider ── */
|
/* ── Section divider ── */
|
||||||
.section-divider {
|
.section-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1788,6 +1828,118 @@
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.progress-strip {
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-body {
|
||||||
|
display: grid;
|
||||||
|
padding: 24px 16px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-sidebar {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-sidebar-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-sidebar-header {
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-list::before {
|
||||||
|
left: calc((100% - 32px) / 10);
|
||||||
|
right: calc((100% - 32px) / 10);
|
||||||
|
top: 27px;
|
||||||
|
bottom: auto;
|
||||||
|
width: auto;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 10px 6px 9px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-help {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
min-height: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: normal;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-status-text {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-inner {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.progress-label,
|
||||||
|
.progress-pct,
|
||||||
|
.btn-save-draft,
|
||||||
|
.step-counter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 9px 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-status-text {
|
||||||
|
font-size: 9.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════
|
/* ══════════════════════════════════════════════
|
||||||
HTTP method picker (Step 4 REST)
|
HTTP method picker (Step 4 REST)
|
||||||
══════════════════════════════════════════════ */
|
══════════════════════════════════════════════ */
|
||||||
|
|||||||
@@ -37,7 +37,11 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); }
|
.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); }
|
||||||
@@ -107,6 +111,7 @@
|
|||||||
.ws-color-swatch {
|
.ws-color-swatch {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Agents</title>
|
<title>Crank — Agents</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
<link rel="stylesheet" href="css/pages.css">
|
||||||
@@ -49,12 +49,12 @@
|
|||||||
<div class="user-dropdown-name">Crank</div>
|
<div class="user-dropdown-name">Crank</div>
|
||||||
<div class="user-dropdown-role" id="user-ws-role">—</div>
|
<div class="user-dropdown-role" id="user-ws-role">—</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="user-dropdown-item" onclick="window.location.href='/settings'">
|
<a class="user-dropdown-item" href="/settings">
|
||||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||||
<span data-i18n="nav.settings">Settings</span>
|
<span data-i18n="nav.settings">Settings</span>
|
||||||
</button>
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
|
<button class="user-dropdown-item danger" @click="window.CrankAuth.logout()">
|
||||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||||
<span data-i18n="nav.logout">Log out</span>
|
<span data-i18n="nav.logout">Log out</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+40
-16
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Agent Keys</title>
|
<title>Crank — Agent Keys</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
<link rel="stylesheet" href="css/pages.css">
|
||||||
@@ -44,6 +44,31 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.key-kind-header-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.key-kind-header-hint {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.api-keys-page-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.api-keys-page-header .page-header-text {
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
.api-keys-page-header .page-header-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.approval-warning-callout {
|
.approval-warning-callout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -63,8 +88,10 @@
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
#keys-table-wrap { display: none; }
|
#keys-table-wrap { display: none; }
|
||||||
.keys-card-list { display: grid; }
|
.keys-card-list { display: grid; }
|
||||||
|
.key-kind-header-control { grid-template-columns: 1fr; justify-content: stretch; width: 100%; }
|
||||||
.key-kind-tabs { width: 100%; }
|
.key-kind-tabs { width: 100%; }
|
||||||
.key-kind-tab { flex: 1; }
|
.key-kind-tab { flex: 1; }
|
||||||
|
.key-kind-header-hint { text-align: left; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||||
@@ -133,16 +160,23 @@
|
|||||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header api-keys-page-header">
|
||||||
<div class="page-header-text">
|
<div class="page-header-text">
|
||||||
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
|
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
|
||||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">These keys connect an MCP client to the MCP server and are issued for a specific agent.</p>
|
<p class="page-subtitle" data-i18n="apikeys.subtitle">These keys connect an MCP client to the MCP server and are issued for a specific agent.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
<button class="btn-primary" id="btn-create-key" type="button">
|
<div class="key-kind-header-control">
|
||||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
<div class="key-kind-tabs" role="tablist" aria-label="Key type">
|
||||||
<span id="btn-create-key-label" data-i18n="apikeys.new">Create key</span>
|
<button class="key-kind-tab active" id="key-kind-mcp-client" type="button" data-key-kind="mcp_client" data-i18n="apikeys.kind.mcp">MCP clients</button>
|
||||||
</button>
|
<button class="key-kind-tab" id="key-kind-approval" type="button" data-key-kind="approval" data-i18n="apikeys.kind.approval">Approvals</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" id="btn-create-key" type="button">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||||
|
<span id="btn-create-key-label" data-i18n="apikeys.new">Create key</span>
|
||||||
|
</button>
|
||||||
|
<div class="field-hint key-kind-header-hint" id="key-kind-hint"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,16 +191,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="section-card-body" style="display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;">
|
|
||||||
<div class="key-kind-tabs" role="tablist" aria-label="Key type">
|
|
||||||
<button class="key-kind-tab active" id="key-kind-mcp-client" type="button" data-key-kind="mcp_client" data-i18n="apikeys.kind.mcp">MCP clients</button>
|
|
||||||
<button class="key-kind-tab" id="key-kind-approval" type="button" data-key-kind="approval" data-i18n="apikeys.kind.approval">Approvals</button>
|
|
||||||
</div>
|
|
||||||
<div class="field-hint" id="key-kind-hint" style="max-width:560px;margin:0;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-card-header">
|
<div class="section-card-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<div class="field-group" style="margin-top:8px;">
|
<div class="field-group" style="margin-top:8px;">
|
||||||
<label class="field-label">Interface language</label>
|
<label class="field-label">Interface language</label>
|
||||||
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
|
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
|
||||||
<button class="lang-btn" data-lang="en" onclick="setLang('en')">
|
<button class="lang-btn" data-lang="en">
|
||||||
<span class="lang-flag">🇬🇧</span>
|
<span class="lang-flag">🇬🇧</span>
|
||||||
<span data-i18n="settings.lang.en">English</span>
|
<span data-i18n="settings.lang.en">English</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
|
<button class="lang-btn" data-lang="ru">
|
||||||
<span class="lang-flag">🇷🇺</span>
|
<span class="lang-flag">🇷🇺</span>
|
||||||
<span data-i18n="settings.lang.ru">Русский</span>
|
<span data-i18n="settings.lang.ru">Русский</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Sign in</title>
|
<title>Crank — Sign in</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/login.css">
|
<link rel="stylesheet" href="css/login.css">
|
||||||
<script src="%CRANK_BUNDLE_LOGIN%"></script>
|
<script src="%CRANK_BUNDLE_LOGIN%"></script>
|
||||||
|
|||||||
+15
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Logs</title>
|
<title>Crank — Logs</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
<link rel="stylesheet" href="css/pages.css">
|
||||||
@@ -78,6 +78,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card approval-panel">
|
||||||
|
<div class="approval-panel-header">
|
||||||
|
<div>
|
||||||
|
<div class="approval-panel-title" data-i18n="approvals.title">Human confirmations</div>
|
||||||
|
<div class="approval-panel-subtitle" data-i18n="approvals.subtitle">Requests waiting for an external user decision and recent results.</div>
|
||||||
|
</div>
|
||||||
|
<button class="refresh-btn approval-refresh-btn" id="approval-refresh-btn" type="button">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
|
||||||
|
<span data-i18n="approvals.refresh">Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="approval-list" id="approval-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="log-toolbar">
|
<div class="log-toolbar">
|
||||||
<div class="live-dot"></div>
|
<div class="live-dot"></div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Secrets</title>
|
<title>Crank — Secrets</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
<link rel="stylesheet" href="css/pages.css">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Settings</title>
|
<title>Crank — Settings</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
<link rel="stylesheet" href="css/pages.css">
|
||||||
@@ -172,11 +172,11 @@
|
|||||||
<label class="field-label" data-i18n="settings.lang.title">Language</label>
|
<label class="field-label" data-i18n="settings.lang.title">Language</label>
|
||||||
<div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div>
|
<div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div>
|
||||||
<div class="lang-switcher">
|
<div class="lang-switcher">
|
||||||
<button class="lang-btn" data-lang="en" type="button" onclick="setLang('en')">
|
<button class="lang-btn" data-lang="en" type="button">
|
||||||
<span class="lang-flag">🇬🇧</span>
|
<span class="lang-flag">🇬🇧</span>
|
||||||
<span data-i18n="settings.lang.en">English</span>
|
<span data-i18n="settings.lang.en">English</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="lang-btn" data-lang="ru" type="button" onclick="setLang('ru')">
|
<button class="lang-btn" data-lang="ru" type="button">
|
||||||
<span class="lang-flag">🇷🇺</span>
|
<span class="lang-flag">🇷🇺</span>
|
||||||
<span data-i18n="settings.lang.ru">Русский</span>
|
<span data-i18n="settings.lang.ru">Русский</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Usage</title>
|
<title>Crank — Usage</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
<link rel="stylesheet" href="css/pages.css">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — New Operation</title>
|
<title>Crank — New Operation</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="../../css/fonts.css">
|
||||||
<link rel="stylesheet" href="../../css/variables.css">
|
<link rel="stylesheet" href="../../css/variables.css">
|
||||||
<link rel="stylesheet" href="../../css/layout.css">
|
<link rel="stylesheet" href="../../css/layout.css">
|
||||||
<link rel="stylesheet" href="../../css/wizard.css">
|
<link rel="stylesheet" href="../../css/wizard.css">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<!-- Searchable combobox -->
|
<!-- Searchable combobox -->
|
||||||
<div class="upstream-combobox" id="upstream-combobox">
|
<div class="upstream-combobox" id="upstream-combobox">
|
||||||
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" onclick="toggleUpstreamDropdown(event)">
|
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" data-wizard-action="toggle-upstream">
|
||||||
<div class="upstream-combobox-value" id="upstream-combobox-value">
|
<div class="upstream-combobox-value" id="upstream-combobox-value">
|
||||||
<span class="upstream-combobox-placeholder" data-i18n="wizard.step2.upstream_placeholder">Выберите API-хост…</span>
|
<span class="upstream-combobox-placeholder" data-i18n="wizard.step2.upstream_placeholder">Выберите API-хост…</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round">
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round">
|
||||||
<circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/>
|
<circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" oninput="filterUpstreams(this.value)" autocomplete="off" spellcheck="false">
|
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" autocomplete="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="upstream-dropdown-list" id="upstream-dropdown-list">
|
<div class="upstream-dropdown-list" id="upstream-dropdown-list">
|
||||||
<!-- populated by JS -->
|
<!-- populated by JS -->
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
<div class="upstream-preview-name" id="upstream-preview-name"></div>
|
<div class="upstream-preview-name" id="upstream-preview-name"></div>
|
||||||
<div class="upstream-preview-url" id="upstream-preview-url"></div>
|
<div class="upstream-preview-url" id="upstream-preview-url"></div>
|
||||||
<span class="upstream-auth-badge" id="upstream-preview-badge"></span>
|
<span class="upstream-auth-badge" id="upstream-preview-badge"></span>
|
||||||
<button class="upstream-preview-change" onclick="beginEditSelectedUpstream(event)" data-i18n="wizard.step2.change">Изменить</button>
|
<button class="upstream-preview-change" data-wizard-action="edit-upstream" data-i18n="wizard.step2.change">Изменить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Register new upstream trigger row -->
|
<!-- Register new upstream trigger row -->
|
||||||
<div class="upstream-new-trigger" id="upstream-new-trigger" onclick="startNewUpstream()">
|
<div class="upstream-new-trigger" id="upstream-new-trigger" data-wizard-action="new-upstream">
|
||||||
<div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio">
|
<div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio">
|
||||||
<div class="upstream-new-trigger-dot"></div>
|
<div class="upstream-new-trigger-dot"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" data-i18n="wizard.step2.auth_selector">Авторизация API-хоста</label>
|
<label class="form-label" data-i18n="wizard.step2.auth_selector">Авторизация API-хоста</label>
|
||||||
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select" onchange="updateUpstreamAuthUi()">
|
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select">
|
||||||
<option value="none" data-i18n="wizard.step2.auth_mode.none">Без авторизации</option>
|
<option value="none" data-i18n="wizard.step2.auth_mode.none">Без авторизации</option>
|
||||||
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Использовать существующий профиль авторизации</option>
|
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Использовать существующий профиль авторизации</option>
|
||||||
<option value="create" data-i18n="wizard.step2.auth_mode.create">Создать профиль авторизации сейчас</option>
|
<option value="create" data-i18n="wizard.step2.auth_mode.create">Создать профиль авторизации сейчас</option>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
<div class="form-row" style="grid-template-columns: 1fr 1fr;">
|
<div class="form-row" style="grid-template-columns: 1fr 1fr;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Тип авторизации</span> <span class="form-label-required" data-i18n="workspace_setup.required">обязательно</span></label>
|
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Тип авторизации</span> <span class="form-label-required" data-i18n="workspace_setup.required">обязательно</span></label>
|
||||||
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select" onchange="updateAuthProfileCreateUi()">
|
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select">
|
||||||
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer-токен</option>
|
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer-токен</option>
|
||||||
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Логин и пароль</option>
|
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Логин и пароль</option>
|
||||||
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API-ключ в заголовке</option>
|
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API-ключ в заголовке</option>
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
<select class="form-select" id="new-auth-profile-secret-id" data-testid="wizard-auth-secret-select"></select>
|
<select class="form-select" id="new-auth-profile-secret-id" data-testid="wizard-auth-secret-select"></select>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||||
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" onclick="openQuickSecretModal(event)" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button>
|
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" data-wizard-action="quick-secret" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button>
|
||||||
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Открыть страницу секретов</a>
|
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Открыть страницу секретов</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,8 +168,8 @@
|
|||||||
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Необязательные заголовки без секретных значений. Токены, пароли и ключи храните через профиль авторизации.</div>
|
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Необязательные заголовки без секретных значений. Токены, пароли и ключи храните через профиль авторизации.</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:8px; margin-top:4px;">
|
<div style="display:flex; gap:8px; margin-top:4px;">
|
||||||
<button class="btn-primary-sm" onclick="saveNewUpstream(event)" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button>
|
<button class="btn-primary-sm" data-wizard-action="save-upstream" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button>
|
||||||
<button class="btn-ghost-sm" onclick="cancelNewUpstream(event)" data-i18n="btn.cancel">Отмена</button>
|
<button class="btn-ghost-sm" data-wizard-action="cancel-upstream" data-i18n="btn.cancel">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,23 +25,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="config-card-body">
|
<div class="config-card-body">
|
||||||
<div class="method-grid">
|
<div class="method-grid">
|
||||||
<button class="method-card" data-method="GET" onclick="selectMethod(this)">
|
<button class="method-card" data-method="GET">
|
||||||
<span class="method-name">GET</span>
|
<span class="method-name">GET</span>
|
||||||
<span class="method-desc" data-i18n="wizard.step3.rest.read">Чтение</span>
|
<span class="method-desc" data-i18n="wizard.step3.rest.read">Чтение</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="method-card active" data-method="POST" onclick="selectMethod(this)">
|
<button class="method-card active" data-method="POST">
|
||||||
<span class="method-name">POST</span>
|
<span class="method-name">POST</span>
|
||||||
<span class="method-desc" data-i18n="wizard.step3.rest.create">Создание</span>
|
<span class="method-desc" data-i18n="wizard.step3.rest.create">Создание</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="method-card" data-method="PUT" onclick="selectMethod(this)">
|
<button class="method-card" data-method="PUT">
|
||||||
<span class="method-name">PUT</span>
|
<span class="method-name">PUT</span>
|
||||||
<span class="method-desc" data-i18n="wizard.step3.rest.replace">Замена</span>
|
<span class="method-desc" data-i18n="wizard.step3.rest.replace">Замена</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="method-card" data-method="PATCH" onclick="selectMethod(this)">
|
<button class="method-card" data-method="PATCH">
|
||||||
<span class="method-name">PATCH</span>
|
<span class="method-name">PATCH</span>
|
||||||
<span class="method-desc" data-i18n="wizard.step3.rest.update">Обновление</span>
|
<span class="method-desc" data-i18n="wizard.step3.rest.update">Обновление</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="method-card" data-method="DELETE" onclick="selectMethod(this)">
|
<button class="method-card" data-method="DELETE">
|
||||||
<span class="method-name">DELETE</span>
|
<span class="method-name">DELETE</span>
|
||||||
<span class="method-desc" data-i18n="wizard.step3.rest.remove">Удаление</span>
|
<span class="method-desc" data-i18n="wizard.step3.rest.remove">Удаление</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -90,4 +90,92 @@
|
|||||||
</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-group">
|
||||||
|
<label class="form-label" for="approval-mode" data-i18n="wizard.approval.mode">Механизм подтверждения</label>
|
||||||
|
<select id="approval-mode" class="form-select">
|
||||||
|
<option value="custom" data-i18n="wizard.approval.mode.custom">Custom MCP Approval</option>
|
||||||
|
<option value="elicitation" data-i18n="wizard.approval.mode.elicitation">MCP Elicitation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-callout" id="approval-custom-info">
|
||||||
|
<svg class="info-callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="8" cy="8" r="6.5"></circle>
|
||||||
|
<path d="M8 11V8M8 5.5V5"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="info-callout-body">
|
||||||
|
<div class="info-callout-title" data-i18n="wizard.approval.custom_title">Custom MCP Approval</div>
|
||||||
|
<div class="info-callout-text" data-i18n="wizard.approval.custom_body">Crank вернёт MCP-клиенту ответ о необходимости подтверждения, идентификатор заявки и адреса для подтверждения или отказа. Ваш MCP-клиент должен распознать такой ответ, показать пользователю окно подтверждения и отправить решение на адрес подтверждения. Для этого адреса нужен отдельный ключ подтверждения агента.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-callout" id="approval-elicitation-info" hidden>
|
||||||
|
<svg class="info-callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="8" cy="8" r="6.5"></circle>
|
||||||
|
<path d="M8 11V8M8 5.5V5"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="info-callout-body">
|
||||||
|
<div class="info-callout-title" data-i18n="wizard.approval.elicitation_title">MCP Elicitation</div>
|
||||||
|
<div class="info-callout-text" data-i18n="wizard.approval.elicitation_body">Crank запросит подтверждение стандартным способом MCP Elicitation. MCP-клиент должен поддерживать эту возможность. Отдельный ключ подтверждения не используется: решение пользователя возвращается по текущему MCP-подключению.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="approval-elicitation-message-group" hidden>
|
||||||
|
<label class="form-label" for="approval-elicitation-message" data-i18n="wizard.approval.elicitation_message">Сообщение для MCP-клиента</label>
|
||||||
|
<textarea id="approval-elicitation-message" class="form-textarea" rows="3" maxlength="240" data-i18n-ph="wizard.approval.elicitation_message_placeholder" placeholder="Подтвердите выполнение операции."></textarea>
|
||||||
|
<div class="form-hint" data-i18n="wizard.approval.elicitation_message_hint">Короткое сообщение для MCP-клиента. Внешний вид окна подтверждения определяет сам клиент.</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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="info-callout" id="approval-payload-info">
|
||||||
|
<svg class="info-callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="8" cy="8" r="6.5"></circle>
|
||||||
|
<path d="M8 11V8M8 5.5V5"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="info-callout-body">
|
||||||
|
<div class="info-callout-title" data-i18n="wizard.approval.payload_title">Параметры подтверждения</div>
|
||||||
|
<div class="info-callout-text" data-i18n="wizard.approval.payload_body">Если включено, Crank передаст параметры вызова вместе с запросом подтверждения. Так внешний интерфейс или MCP-клиент сможет показать пользователю, какое действие он подтверждает. Если параметры содержат чувствительные данные, выключите эту опцию.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /step-pane-3-rest -->
|
</div><!-- /step-pane-3-rest -->
|
||||||
|
|||||||
@@ -126,85 +126,6 @@ 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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Workspace</title>
|
<title>Crank — Workspace</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
<link rel="stylesheet" href="css/pages.css">
|
||||||
@@ -24,10 +24,10 @@
|
|||||||
</div>
|
</div>
|
||||||
Crank
|
Crank
|
||||||
</a>
|
</a>
|
||||||
<a href="javascript:history.back()" class="ws-setup-back">
|
<button type="button" class="ws-setup-back" data-history-back>
|
||||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
||||||
<span data-i18n="workspace_setup.back">Back</span>
|
<span data-i18n="workspace_setup.back">Back</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
@@ -48,27 +48,27 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div>
|
<div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div>
|
||||||
<div class="ws-color-swatches">
|
<div class="ws-color-swatches">
|
||||||
<div class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" onclick="pickColor(this)" title="Teal"></div>
|
<button class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" type="button" title="Teal"></button>
|
||||||
<div class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" onclick="pickColor(this)" title="Purple"></div>
|
<button class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" type="button" title="Purple"></button>
|
||||||
<div class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" onclick="pickColor(this)" title="Cyan"></div>
|
<button class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" type="button" title="Cyan"></button>
|
||||||
<div class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" onclick="pickColor(this)" title="Amber"></div>
|
<button class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" type="button" title="Amber"></button>
|
||||||
<div class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" onclick="pickColor(this)" title="Green"></div>
|
<button class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" type="button" title="Green"></button>
|
||||||
<div class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" onclick="pickColor(this)" title="Red"></div>
|
<button class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" type="button" title="Red"></button>
|
||||||
<div class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" onclick="pickColor(this)" title="Indigo"></div>
|
<button class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" type="button" title="Indigo"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom:14px;">
|
<div class="form-group" style="margin-bottom:14px;">
|
||||||
<label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
<label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||||
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off" oninput="onWsNameInput(this.value)">
|
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom:14px;">
|
<div class="form-group" style="margin-bottom:14px;">
|
||||||
<label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
<label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||||
<div style="position:relative;">
|
<div style="position:relative;">
|
||||||
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
|
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
|
||||||
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;" oninput="onWsSlugInput(this.value)">
|
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
|
<div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
|
|
||||||
<!-- ── Actions ── -->
|
<!-- ── Actions ── -->
|
||||||
<div class="ws-setup-actions">
|
<div class="ws-setup-actions">
|
||||||
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</a>
|
<button type="button" class="btn-ghost-sm" data-history-back style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</button>
|
||||||
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" onclick="submitForm()" data-i18n="workspace_setup.actions.save">Save changes</button>
|
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" data-i18n="workspace_setup.actions.save">Save changes</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ws-setup-footer-note" id="footer-note" hidden>
|
<div class="ws-setup-footer-note" id="footer-note" hidden>
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crank — Operations</title>
|
<title>Crank — Operations</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/catalog.css">
|
<link rel="stylesheet" href="css/catalog.css">
|
||||||
|
|||||||
+6
-8
@@ -124,14 +124,6 @@
|
|||||||
return encoded ? ('?' + encoded) : '';
|
return encoded ? ('?' + encoded) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function postBytes(path, bytes, fileName) {
|
|
||||||
return request(API_BASE + path, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
|
|
||||||
body: bytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.CrankApi = {
|
window.CrankApi = {
|
||||||
login: function(payload) {
|
login: function(payload) {
|
||||||
return request(AUTH_BASE + '/login', {
|
return request(AUTH_BASE + '/login', {
|
||||||
@@ -327,6 +319,12 @@
|
|||||||
getLog: function(workspaceId, logId) {
|
getLog: function(workspaceId, logId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
|
||||||
},
|
},
|
||||||
|
listApprovals: function(workspaceId, params) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals' + query(params));
|
||||||
|
},
|
||||||
|
getApproval: function(workspaceId, approvalId) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId));
|
||||||
|
},
|
||||||
getUsageOverview: function(workspaceId, params) {
|
getUsageOverview: function(workspaceId, params) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
|
||||||
},
|
},
|
||||||
|
|||||||
+69
-33
@@ -281,6 +281,27 @@ var TRANSLATIONS = {
|
|||||||
'logs.live.off.body': 'Automatic polling is paused.',
|
'logs.live.off.body': 'Automatic polling is paused.',
|
||||||
'logs.refresh.title': 'Logs refreshed',
|
'logs.refresh.title': 'Logs refreshed',
|
||||||
'logs.refresh.body': 'The latest invocation records were loaded for the current workspace.',
|
'logs.refresh.body': 'The latest invocation records were loaded for the current workspace.',
|
||||||
|
'approvals.title': 'Human confirmations',
|
||||||
|
'approvals.subtitle': 'Requests waiting for an external user decision and recent results.',
|
||||||
|
'approvals.refresh': 'Refresh',
|
||||||
|
'approvals.refresh.title': 'Confirmations refreshed',
|
||||||
|
'approvals.refresh.body': 'The latest confirmation requests were loaded.',
|
||||||
|
'approvals.loading': 'Loading confirmation requests…',
|
||||||
|
'approvals.empty': 'There are no confirmation requests yet.',
|
||||||
|
'approvals.error.load': 'Failed to load confirmation requests',
|
||||||
|
'approvals.untitled': 'Confirmation request',
|
||||||
|
'approvals.operation': 'Operation',
|
||||||
|
'approvals.agent': 'Agent',
|
||||||
|
'approvals.expires_at': 'Expires',
|
||||||
|
'approvals.updated_at': 'Updated',
|
||||||
|
'approvals.request': 'Request',
|
||||||
|
'approvals.response': 'Result',
|
||||||
|
'approvals.status.pending': 'Pending',
|
||||||
|
'approvals.status.approved': 'Approved',
|
||||||
|
'approvals.status.denied': 'Denied',
|
||||||
|
'approvals.status.expired': 'Expired',
|
||||||
|
'approvals.status.completed': 'Completed',
|
||||||
|
'approvals.status.failed': 'Failed',
|
||||||
|
|
||||||
// Usage page
|
// Usage page
|
||||||
'usage.title': 'Usage',
|
'usage.title': 'Usage',
|
||||||
@@ -577,23 +598,20 @@ var TRANSLATIONS = {
|
|||||||
'wizard.approval.subtitle': 'Enable this for actions that must not run without an explicit user decision.',
|
'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_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.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.mode': 'Confirmation mechanism',
|
||||||
'wizard.approval.risk.normal': 'Normal action',
|
'wizard.approval.mode.custom': 'Custom MCP Approval',
|
||||||
'wizard.approval.risk.dangerous': 'Dangerous action',
|
'wizard.approval.mode.elicitation': 'MCP Elicitation',
|
||||||
'wizard.approval.risk.financial': 'Financial action',
|
'wizard.approval.custom_title': 'Custom MCP Approval',
|
||||||
'wizard.approval.risk.irreversible': 'Irreversible action',
|
'wizard.approval.custom_body': 'Crank returns approval_required to the MCP client with approval_id, approval_url and approve/deny links. Your MCP client must handle this response, show confirmation to the user and send the decision to the approval endpoint. The approval endpoint requires a separate agent approval key.',
|
||||||
|
'wizard.approval.elicitation_title': 'MCP Elicitation',
|
||||||
|
'wizard.approval.elicitation_body': 'Crank asks for confirmation through standard MCP Elicitation. The MCP client must support the elicitation capability. No separate approval key is used: the user decision returns through the current MCP session.',
|
||||||
|
'wizard.approval.elicitation_message': 'Message for the MCP client',
|
||||||
|
'wizard.approval.elicitation_message_placeholder': 'Confirm operation execution.',
|
||||||
|
'wizard.approval.elicitation_message_hint': 'Short protocol message. The MCP client still controls the confirmation UI.',
|
||||||
'wizard.approval.ttl': 'How long to wait for confirmation',
|
'wizard.approval.ttl': 'How long to wait for confirmation',
|
||||||
'wizard.approval.confirmation_title': 'Confirmation title',
|
'wizard.approval.show_payload': 'Send call parameters to the confirmation flow',
|
||||||
'wizard.approval.confirmation_title_hint': 'This text will be shown by the external confirmation interface.',
|
'wizard.approval.payload_title': 'Confirmation parameters',
|
||||||
'wizard.approval.confirmation_body': 'Description for the user',
|
'wizard.approval.payload_body': 'When enabled, Crank sends call parameters into the approval flow so the external UI or MCP client can show the user what action is being confirmed. Disable this option if parameters contain sensitive data.',
|
||||||
'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',
|
||||||
@@ -1127,6 +1145,27 @@ var TRANSLATIONS = {
|
|||||||
'logs.live.off.body': 'Автоматический опрос остановлен.',
|
'logs.live.off.body': 'Автоматический опрос остановлен.',
|
||||||
'logs.refresh.title': 'Логи обновлены',
|
'logs.refresh.title': 'Логи обновлены',
|
||||||
'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.',
|
'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.',
|
||||||
|
'approvals.title': 'Подтверждения человеком',
|
||||||
|
'approvals.subtitle': 'Заявки, которые ожидают решения пользователя, и последние результаты.',
|
||||||
|
'approvals.refresh': 'Обновить',
|
||||||
|
'approvals.refresh.title': 'Подтверждения обновлены',
|
||||||
|
'approvals.refresh.body': 'Получены последние заявки на подтверждение.',
|
||||||
|
'approvals.loading': 'Загрузка заявок на подтверждение…',
|
||||||
|
'approvals.empty': 'Заявок на подтверждение пока нет.',
|
||||||
|
'approvals.error.load': 'Не удалось загрузить заявки на подтверждение',
|
||||||
|
'approvals.untitled': 'Заявка на подтверждение',
|
||||||
|
'approvals.operation': 'Операция',
|
||||||
|
'approvals.agent': 'Агент',
|
||||||
|
'approvals.expires_at': 'Истекает',
|
||||||
|
'approvals.updated_at': 'Обновлено',
|
||||||
|
'approvals.request': 'Запрос',
|
||||||
|
'approvals.response': 'Результат',
|
||||||
|
'approvals.status.pending': 'Ожидает',
|
||||||
|
'approvals.status.approved': 'Подтверждено',
|
||||||
|
'approvals.status.denied': 'Отклонено',
|
||||||
|
'approvals.status.expired': 'Истекло',
|
||||||
|
'approvals.status.completed': 'Выполнено',
|
||||||
|
'approvals.status.failed': 'Ошибка',
|
||||||
|
|
||||||
// Usage page
|
// Usage page
|
||||||
'usage.title': 'Использование',
|
'usage.title': 'Использование',
|
||||||
@@ -1422,24 +1461,21 @@ var TRANSLATIONS = {
|
|||||||
'wizard.approval.title': 'Подтверждение человеком',
|
'wizard.approval.title': 'Подтверждение человеком',
|
||||||
'wizard.approval.subtitle': 'Включайте для действий, которые нельзя выполнять без явного решения пользователя.',
|
'wizard.approval.subtitle': 'Включайте для действий, которые нельзя выполнять без явного решения пользователя.',
|
||||||
'wizard.approval.required_label': 'Требовать подтверждение перед выполнением',
|
'wizard.approval.required_label': 'Требовать подтверждение перед выполнением',
|
||||||
'wizard.approval.required_desc': 'MCP клиент получит ожидающий запрос, а действие выполнится только после подтверждения через отдельный эндпоинт подтверждения.',
|
'wizard.approval.required_desc': 'Инструмент не выполнится сразу. Crank сначала запросит подтверждение выбранным способом.',
|
||||||
'wizard.approval.risk_level': 'Уровень риска',
|
'wizard.approval.mode': 'Механизм подтверждения',
|
||||||
'wizard.approval.risk.normal': 'Обычное действие',
|
'wizard.approval.mode.custom': 'Custom MCP Approval',
|
||||||
'wizard.approval.risk.dangerous': 'Опасное действие',
|
'wizard.approval.mode.elicitation': 'MCP Elicitation',
|
||||||
'wizard.approval.risk.financial': 'Финансовое действие',
|
'wizard.approval.custom_title': 'Custom MCP Approval',
|
||||||
'wizard.approval.risk.irreversible': 'Необратимое действие',
|
'wizard.approval.custom_body': 'Crank вернёт MCP-клиенту ответ о необходимости подтверждения, идентификатор заявки и адреса для подтверждения или отказа. Ваш MCP-клиент должен распознать такой ответ, показать пользователю окно подтверждения и отправить решение на адрес подтверждения. Для этого адреса нужен отдельный ключ подтверждения агента.',
|
||||||
|
'wizard.approval.elicitation_title': 'MCP Elicitation',
|
||||||
|
'wizard.approval.elicitation_body': 'Crank запросит подтверждение стандартным способом MCP Elicitation. MCP-клиент должен поддерживать эту возможность. Отдельный ключ подтверждения не используется: решение пользователя возвращается по текущему MCP-подключению.',
|
||||||
|
'wizard.approval.elicitation_message': 'Сообщение для MCP-клиента',
|
||||||
|
'wizard.approval.elicitation_message_placeholder': 'Подтвердите выполнение операции.',
|
||||||
|
'wizard.approval.elicitation_message_hint': 'Короткое сообщение для MCP-клиента. Внешний вид окна подтверждения определяет сам клиент.',
|
||||||
'wizard.approval.ttl': 'Сколько ждать подтверждение',
|
'wizard.approval.ttl': 'Сколько ждать подтверждение',
|
||||||
'wizard.approval.confirmation_title': 'Заголовок подтверждения',
|
'wizard.approval.show_payload': 'Передавать параметры вызова в подтверждение',
|
||||||
'wizard.approval.confirmation_title_hint': 'Этот текст увидит внешний интерфейс подтверждения.',
|
'wizard.approval.payload_title': 'Параметры подтверждения',
|
||||||
'wizard.approval.confirmation_body': 'Описание для пользователя',
|
'wizard.approval.payload_body': 'Если включено, Crank передаст параметры вызова вместе с запросом подтверждения. Так внешний интерфейс или MCP-клиент сможет показать пользователю, какое действие он подтверждает. Если параметры содержат чувствительные данные, выключите эту опцию.',
|
||||||
'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': 'Проверка и публикация',
|
||||||
|
|||||||
+168
-4
@@ -11,9 +11,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
workspaceId: null,
|
workspaceId: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
loadError: '',
|
loadError: '',
|
||||||
|
approvals: [],
|
||||||
|
approvalsLoading: false,
|
||||||
|
approvalsError: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
var logList = document.getElementById('log-list');
|
var logList = document.getElementById('log-list');
|
||||||
|
var approvalList = document.getElementById('approval-list');
|
||||||
|
var approvalRefreshBtn = document.getElementById('approval-refresh-btn');
|
||||||
var logSearch = document.getElementById('log-search');
|
var logSearch = document.getElementById('log-search');
|
||||||
var refreshBtn = document.getElementById('refresh-btn');
|
var refreshBtn = document.getElementById('refresh-btn');
|
||||||
var timeRangeSel = document.getElementById('time-range');
|
var timeRangeSel = document.getElementById('time-range');
|
||||||
@@ -54,6 +59,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return date.toISOString().slice(11, 23);
|
return date.toISOString().slice(11, 23);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(timestamp) {
|
||||||
|
if (!timestamp) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var date = new Date(timestamp);
|
||||||
|
return date.toLocaleString(window.CrankLocale || undefined, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function element(tag, className, text) {
|
function element(tag, className, text) {
|
||||||
var node = document.createElement(tag);
|
var node = document.createElement(tag);
|
||||||
if (className) node.className = className;
|
if (className) node.className = className;
|
||||||
@@ -111,6 +129,109 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeApproval(record) {
|
||||||
|
var approval = record.approval || record;
|
||||||
|
return {
|
||||||
|
id: approval.id,
|
||||||
|
agentId: approval.agent_id,
|
||||||
|
operationId: approval.operation_id,
|
||||||
|
operationVersion: approval.operation_version,
|
||||||
|
status: approval.status,
|
||||||
|
riskLevel: approval.risk_level,
|
||||||
|
requestPayload: approval.request_payload,
|
||||||
|
responsePayload: approval.response_payload,
|
||||||
|
createdAt: approval.created_at,
|
||||||
|
expiresAt: approval.expires_at,
|
||||||
|
decidedAt: approval.decided_at,
|
||||||
|
note: approval.decision_note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalStatusLabel(status) {
|
||||||
|
var key = 'approvals.status.' + status;
|
||||||
|
var translated = tKey(key);
|
||||||
|
return translated === key ? status : translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApprovals() {
|
||||||
|
if (!approvalList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalList.innerHTML = '';
|
||||||
|
|
||||||
|
if (state.approvalsLoading && state.approvals.length === 0) {
|
||||||
|
var loading = element('div', 'approval-empty', tKey('approvals.loading'));
|
||||||
|
approvalList.appendChild(loading);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.approvalsError) {
|
||||||
|
var error = element('div', 'approval-empty approval-empty-error', state.approvalsError);
|
||||||
|
approvalList.appendChild(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.approvals.length) {
|
||||||
|
var empty = element('div', 'approval-empty', tKey('approvals.empty'));
|
||||||
|
approvalList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragment = document.createDocumentFragment();
|
||||||
|
state.approvals.forEach(function (item) {
|
||||||
|
var card = element('article', 'approval-item approval-' + item.status);
|
||||||
|
|
||||||
|
var header = element('div', 'approval-item-header');
|
||||||
|
var titleWrap = element('div', 'approval-item-title-wrap');
|
||||||
|
titleWrap.appendChild(element('div', 'approval-item-title', tKey('approvals.untitled') + ' ' + item.id));
|
||||||
|
|
||||||
|
var meta = element('div', 'approval-item-meta');
|
||||||
|
meta.textContent = [
|
||||||
|
tKey('approvals.operation') + ': ' + item.operationId + ' v' + item.operationVersion,
|
||||||
|
tKey('approvals.agent') + ': ' + item.agentId,
|
||||||
|
].join(' · ');
|
||||||
|
titleWrap.appendChild(meta);
|
||||||
|
header.appendChild(titleWrap);
|
||||||
|
|
||||||
|
var badge = element('span', 'approval-status approval-status-' + item.status, approvalStatusLabel(item.status));
|
||||||
|
header.appendChild(badge);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
var timing = element('div', 'approval-timing');
|
||||||
|
timing.textContent = item.status === 'pending'
|
||||||
|
? tKey('approvals.expires_at') + ': ' + formatDateTime(item.expiresAt)
|
||||||
|
: tKey('approvals.updated_at') + ': ' + formatDateTime(item.decidedAt || item.createdAt);
|
||||||
|
card.appendChild(timing);
|
||||||
|
|
||||||
|
var payloadGrid = element('div', 'approval-payload-grid');
|
||||||
|
var requestBlock = element('div', 'approval-payload');
|
||||||
|
requestBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.request')));
|
||||||
|
var requestPre = element('pre', 'approval-payload-code');
|
||||||
|
requestPre.textContent = formatJson(item.requestPayload);
|
||||||
|
requestBlock.appendChild(requestPre);
|
||||||
|
payloadGrid.appendChild(requestBlock);
|
||||||
|
|
||||||
|
if (item.responsePayload !== null && item.responsePayload !== undefined) {
|
||||||
|
var responseBlock = element('div', 'approval-payload');
|
||||||
|
responseBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.response')));
|
||||||
|
var responsePre = element('pre', 'approval-payload-code');
|
||||||
|
responsePre.textContent = formatJson(item.responsePayload);
|
||||||
|
responseBlock.appendChild(responsePre);
|
||||||
|
payloadGrid.appendChild(responseBlock);
|
||||||
|
}
|
||||||
|
card.appendChild(payloadGrid);
|
||||||
|
|
||||||
|
if (item.note) {
|
||||||
|
card.appendChild(element('div', 'approval-note', item.note));
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
approvalList.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
function renderEmpty(title, message) {
|
function renderEmpty(title, message) {
|
||||||
logList.innerHTML = '';
|
logList.innerHTML = '';
|
||||||
var empty = element('div', 'empty-state');
|
var empty = element('div', 'empty-state');
|
||||||
@@ -306,6 +427,39 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadApprovals() {
|
||||||
|
if (!window.CrankApi) {
|
||||||
|
state.approvalsError = tKey('logs.error.api');
|
||||||
|
renderApprovals();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.workspaceId = currentWorkspaceId();
|
||||||
|
if (!state.workspaceId) {
|
||||||
|
state.approvalsError = tKey('logs.error.workspace');
|
||||||
|
renderApprovals();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.approvalsLoading = true;
|
||||||
|
state.approvalsError = '';
|
||||||
|
renderApprovals();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await window.CrankApi.listApprovals(state.workspaceId, { limit: 20 });
|
||||||
|
state.approvals = (response && response.items ? response.items : []).map(normalizeApproval);
|
||||||
|
} catch (error) {
|
||||||
|
state.approvalsError = error.message || tKey('approvals.error.load');
|
||||||
|
} finally {
|
||||||
|
state.approvalsLoading = false;
|
||||||
|
renderApprovals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOperationalData() {
|
||||||
|
await Promise.all([loadLogs(), loadApprovals()]);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLogDetail(logId) {
|
async function loadLogDetail(logId) {
|
||||||
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
|
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
|
||||||
return;
|
return;
|
||||||
@@ -343,7 +497,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (!state.liveMode) {
|
if (!state.liveMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.timer = setInterval(loadLogs, 4000);
|
state.timer = setInterval(refreshOperationalData, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLive() {
|
function toggleLive() {
|
||||||
@@ -386,6 +540,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (approvalRefreshBtn) {
|
||||||
|
approvalRefreshBtn.addEventListener('click', function () {
|
||||||
|
loadApprovals().then(function () {
|
||||||
|
if (!state.approvalsError && window.CrankUi) {
|
||||||
|
window.CrankUi.info(tKey('approvals.refresh.body'), tKey('approvals.refresh.title'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (timeRangeSel) {
|
if (timeRangeSel) {
|
||||||
timeRangeSel.value = state.period;
|
timeRangeSel.value = state.period;
|
||||||
timeRangeSel.addEventListener('change', function () {
|
timeRangeSel.addEventListener('change', function () {
|
||||||
@@ -405,15 +569,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
window.addEventListener('crank:workspacechange', function () {
|
window.addEventListener('crank:workspacechange', function () {
|
||||||
state.details = {};
|
state.details = {};
|
||||||
state.openId = null;
|
state.openId = null;
|
||||||
loadLogs();
|
refreshOperationalData();
|
||||||
});
|
});
|
||||||
|
|
||||||
setLiveState();
|
setLiveState();
|
||||||
startPolling();
|
startPolling();
|
||||||
|
|
||||||
if (window.whenWorkspacesReady) {
|
if (window.whenWorkspacesReady) {
|
||||||
window.whenWorkspacesReady().finally(loadLogs);
|
window.whenWorkspacesReady().finally(refreshOperationalData);
|
||||||
} else {
|
} else {
|
||||||
loadLogs();
|
refreshOperationalData();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -330,6 +330,9 @@ async function loadWorkspaceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initSettingsPage() {
|
async function initSettingsPage() {
|
||||||
|
document.querySelectorAll('.lang-btn[data-lang]').forEach(function(button) {
|
||||||
|
button.addEventListener('click', function() { setLang(button.dataset.lang); });
|
||||||
|
});
|
||||||
bindSectionNavigation();
|
bindSectionNavigation();
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
await loadCapabilities();
|
await loadCapabilities();
|
||||||
|
|||||||
Vendored
-5
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+17
-20
@@ -41,17 +41,16 @@ function normalizeApprovalTtlSeconds(value) {
|
|||||||
function buildApprovalPolicy() {
|
function buildApprovalPolicy() {
|
||||||
if (!checkedValue('approval-required')) return null;
|
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 {
|
return {
|
||||||
required: true,
|
required: true,
|
||||||
risk_level: textValue('approval-risk-level') || 'normal',
|
mode: textValue('approval-mode') || 'custom',
|
||||||
confirmation_title: title,
|
risk_level: 'normal',
|
||||||
confirmation_body_template: body,
|
|
||||||
ttl_seconds: normalizeApprovalTtlSeconds(textValue('approval-ttl-seconds')),
|
ttl_seconds: normalizeApprovalTtlSeconds(textValue('approval-ttl-seconds')),
|
||||||
show_payload_preview: checkedValue('approval-show-payload-preview'),
|
show_payload_preview: checkedValue('approval-show-payload-preview'),
|
||||||
payload_preview_mode: textValue('approval-payload-preview-mode') || 'summary',
|
payload_preview_mode: 'summary',
|
||||||
|
elicitation_message: textValue('approval-mode') === 'elicitation'
|
||||||
|
? (textValue('approval-elicitation-message') || null)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,15 +190,13 @@ function setApprovalPolicyEditor(policy) {
|
|||||||
var enabled = !!(policy && policy.required);
|
var enabled = !!(policy && policy.required);
|
||||||
var required = document.getElementById('approval-required');
|
var required = document.getElementById('approval-required');
|
||||||
if (required) required.checked = enabled;
|
if (required) required.checked = enabled;
|
||||||
setValue('approval-risk-level', policy && policy.risk_level ? policy.risk_level : 'normal');
|
setValue('approval-mode', policy && policy.mode ? policy.mode : 'custom');
|
||||||
|
setValue('approval-elicitation-message', policy && policy.elicitation_message ? policy.elicitation_message : '');
|
||||||
setValue('approval-ttl-seconds', policy && policy.ttl_seconds ? String(policy.ttl_seconds) : '300');
|
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');
|
var showPayload = document.getElementById('approval-show-payload-preview');
|
||||||
if (showPayload) {
|
if (showPayload) {
|
||||||
showPayload.checked = !policy || policy.show_payload_preview !== false;
|
showPayload.checked = !policy || policy.show_payload_preview !== false;
|
||||||
}
|
}
|
||||||
setValue('approval-payload-preview-mode', policy && policy.payload_preview_mode ? policy.payload_preview_mode : 'summary');
|
|
||||||
updateApprovalPolicyUi();
|
updateApprovalPolicyUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,24 +204,24 @@ function updateApprovalPolicyUi() {
|
|||||||
var enabled = checkedValue('approval-required');
|
var enabled = checkedValue('approval-required');
|
||||||
var toggle = document.getElementById('approval-required-toggle');
|
var toggle = document.getElementById('approval-required-toggle');
|
||||||
var fields = document.getElementById('approval-config-fields');
|
var fields = document.getElementById('approval-config-fields');
|
||||||
|
var mode = textValue('approval-mode') || 'custom';
|
||||||
|
var customInfo = document.getElementById('approval-custom-info');
|
||||||
|
var elicitationInfo = document.getElementById('approval-elicitation-info');
|
||||||
|
var elicitationMessage = document.getElementById('approval-elicitation-message-group');
|
||||||
if (toggle) toggle.classList.toggle('on', enabled);
|
if (toggle) toggle.classList.toggle('on', enabled);
|
||||||
if (fields) fields.hidden = !enabled;
|
if (fields) fields.hidden = !enabled;
|
||||||
|
if (customInfo) customInfo.hidden = mode !== 'custom';
|
||||||
var title = textValue('approval-title') || tKey('wizard.approval.default_title');
|
if (elicitationInfo) elicitationInfo.hidden = mode !== 'elicitation';
|
||||||
var body = textValue('approval-body') || tKey('wizard.approval.default_body');
|
if (elicitationMessage) elicitationMessage.hidden = mode !== 'elicitation';
|
||||||
setTextContent('approval-preview-title', title);
|
|
||||||
setTextContent('approval-preview-body', body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindApprovalPolicyControls() {
|
function bindApprovalPolicyControls() {
|
||||||
[
|
[
|
||||||
'approval-required',
|
'approval-required',
|
||||||
'approval-risk-level',
|
'approval-mode',
|
||||||
|
'approval-elicitation-message',
|
||||||
'approval-ttl-seconds',
|
'approval-ttl-seconds',
|
||||||
'approval-title',
|
|
||||||
'approval-body',
|
|
||||||
'approval-show-payload-preview',
|
'approval-show-payload-preview',
|
||||||
'approval-payload-preview-mode',
|
|
||||||
].forEach(function(id) {
|
].forEach(function(id) {
|
||||||
var element = document.getElementById(id);
|
var element = document.getElementById(id);
|
||||||
if (!element || element.dataset.approvalBound === 'true') return;
|
if (!element || element.dataset.approvalBound === 'true') return;
|
||||||
|
|||||||
@@ -346,6 +346,25 @@
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapMappingControl(labelText, control) {
|
||||||
|
var wrapper = document.createElement('label');
|
||||||
|
wrapper.className = 'mapping-field';
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.className = 'mapping-field-label';
|
||||||
|
label.textContent = labelText;
|
||||||
|
wrapper.appendChild(label);
|
||||||
|
wrapper.appendChild(control);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMappingArrow() {
|
||||||
|
var arrow = document.createElement('span');
|
||||||
|
arrow.className = 'mapping-arrow';
|
||||||
|
arrow.setAttribute('aria-hidden', 'true');
|
||||||
|
arrow.textContent = '→';
|
||||||
|
return arrow;
|
||||||
|
}
|
||||||
|
|
||||||
function renderRequestRows(rows) {
|
function renderRequestRows(rows) {
|
||||||
var root = field('wizard-request-mapping-rows');
|
var root = field('wizard-request-mapping-rows');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -364,7 +383,10 @@
|
|||||||
|
|
||||||
var input = makeInput(row.input, 'form-input input-mono mapping-source', 'base');
|
var input = makeInput(row.input, 'form-input input-mono mapping-source', 'base');
|
||||||
input.dataset.role = 'input';
|
input.dataset.role = 'input';
|
||||||
item.appendChild(input);
|
input.title = 'Поле, которое MCP клиент передает инструменту';
|
||||||
|
item.appendChild(wrapMappingControl('Из инструмента', input));
|
||||||
|
|
||||||
|
item.appendChild(makeMappingArrow());
|
||||||
|
|
||||||
var select = document.createElement('select');
|
var select = document.createElement('select');
|
||||||
select.className = 'form-select mapping-target';
|
select.className = 'form-select mapping-target';
|
||||||
@@ -378,16 +400,17 @@
|
|||||||
select.appendChild(makeOption(entry[0], entry[1], entry[0] === row.target));
|
select.appendChild(makeOption(entry[0], entry[1], entry[0] === row.target));
|
||||||
});
|
});
|
||||||
select.addEventListener('change', syncVisualMappingsToYaml);
|
select.addEventListener('change', syncVisualMappingsToYaml);
|
||||||
item.appendChild(select);
|
item.appendChild(wrapMappingControl('Куда в API', select));
|
||||||
|
|
||||||
var apiName = makeInput(row.apiName || row.input, 'form-input input-mono mapping-api-name', 'base');
|
var apiName = makeInput(row.apiName || row.input, 'form-input input-mono mapping-api-name', 'base');
|
||||||
apiName.dataset.role = 'apiName';
|
apiName.dataset.role = 'apiName';
|
||||||
item.appendChild(apiName);
|
apiName.title = 'Имя path, query, header или body-поля в API-запросе';
|
||||||
|
item.appendChild(wrapMappingControl('Имя в API', apiName));
|
||||||
|
|
||||||
var defaultValue = makeInput(row.defaultValue, 'form-input input-mono mapping-default-value', 'по умолчанию');
|
var defaultValue = makeInput(row.defaultValue, 'form-input input-mono mapping-default-value', 'если не передано');
|
||||||
defaultValue.dataset.role = 'defaultValue';
|
defaultValue.dataset.role = 'defaultValue';
|
||||||
defaultValue.title = 'Значение по умолчанию, если поле не передано';
|
defaultValue.title = 'Необязательно. Это значение уйдет в API, если агент не передал поле инструмента.';
|
||||||
item.appendChild(defaultValue);
|
item.appendChild(wrapMappingControl('Если пусто', defaultValue));
|
||||||
|
|
||||||
var transform = document.createElement('select');
|
var transform = document.createElement('select');
|
||||||
transform.className = 'form-select mapping-transform';
|
transform.className = 'form-select mapping-transform';
|
||||||
@@ -398,7 +421,7 @@
|
|||||||
});
|
});
|
||||||
transform.title = 'Простое преобразование перед отправкой в API';
|
transform.title = 'Простое преобразование перед отправкой в API';
|
||||||
transform.addEventListener('change', syncVisualMappingsToYaml);
|
transform.addEventListener('change', syncVisualMappingsToYaml);
|
||||||
item.appendChild(transform);
|
item.appendChild(wrapMappingControl('Преобразование', transform));
|
||||||
|
|
||||||
item.appendChild(makeRemoveButton(item));
|
item.appendChild(makeRemoveButton(item));
|
||||||
return item;
|
return item;
|
||||||
@@ -422,11 +445,15 @@
|
|||||||
|
|
||||||
var responsePath = makeInput(row.responsePath, 'form-input input-mono mapping-response-path', 'rates.EUR');
|
var responsePath = makeInput(row.responsePath, 'form-input input-mono mapping-response-path', 'rates.EUR');
|
||||||
responsePath.dataset.role = 'responsePath';
|
responsePath.dataset.role = 'responsePath';
|
||||||
item.appendChild(responsePath);
|
responsePath.title = 'Поле из ответа API';
|
||||||
|
item.appendChild(wrapMappingControl('Из ответа API', responsePath));
|
||||||
|
|
||||||
|
item.appendChild(makeMappingArrow());
|
||||||
|
|
||||||
var output = makeInput(row.output, 'form-input input-mono mapping-output-field', 'rate');
|
var output = makeInput(row.output, 'form-input input-mono mapping-output-field', 'rate');
|
||||||
output.dataset.role = 'output';
|
output.dataset.role = 'output';
|
||||||
item.appendChild(output);
|
output.title = 'Поле результата, которое получит MCP клиент';
|
||||||
|
item.appendChild(wrapMappingControl('В результат инструмента', output));
|
||||||
item.appendChild(makeRemoveButton(item));
|
item.appendChild(makeRemoveButton(item));
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async function initWizardPage() {
|
|||||||
await loadProtocolCapabilities();
|
await loadProtocolCapabilities();
|
||||||
|
|
||||||
await loadWizardPanels([1, 2, 3, 4, 5]);
|
await loadWizardPanels([1, 2, 3, 4, 5]);
|
||||||
|
bindWizardPanelActions();
|
||||||
if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') {
|
if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') {
|
||||||
await window.CrankOverlay.render(document, {
|
await window.CrankOverlay.render(document, {
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
@@ -248,6 +249,36 @@ function selectMethod(btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindWizardPanelActions() {
|
||||||
|
var upstreamSearch = document.getElementById('upstream-search');
|
||||||
|
var authMode = document.getElementById('new-upstream-auth-mode');
|
||||||
|
var authKind = document.getElementById('new-auth-profile-kind');
|
||||||
|
|
||||||
|
if (upstreamSearch) {
|
||||||
|
upstreamSearch.addEventListener('input', function(event) {
|
||||||
|
filterUpstreams(event.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (authMode) authMode.addEventListener('change', updateUpstreamAuthUi);
|
||||||
|
if (authKind) authKind.addEventListener('change', updateAuthProfileCreateUi);
|
||||||
|
|
||||||
|
document.querySelectorAll('.method-card[data-method]').forEach(function(button) {
|
||||||
|
button.addEventListener('click', function() { selectMethod(button); });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-wizard-action]').forEach(function(element) {
|
||||||
|
element.addEventListener('click', function(event) {
|
||||||
|
var action = element.dataset.wizardAction;
|
||||||
|
if (action === 'toggle-upstream') toggleUpstreamDropdown(event);
|
||||||
|
if (action === 'edit-upstream') beginEditSelectedUpstream(event);
|
||||||
|
if (action === 'new-upstream') startNewUpstream();
|
||||||
|
if (action === 'quick-secret') openQuickSecretModal(event);
|
||||||
|
if (action === 'save-upstream') void saveNewUpstream(event);
|
||||||
|
if (action === 'cancel-upstream') cancelNewUpstream(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,17 @@ async function exportWorkspaceSnapshot() {
|
|||||||
async function initPage() {
|
async function initPage() {
|
||||||
updatePageMode();
|
updatePageMode();
|
||||||
|
|
||||||
|
document.querySelectorAll('.ws-color-swatch').forEach(function(swatch) {
|
||||||
|
swatch.addEventListener('click', function() { pickColor(swatch); });
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-history-back]').forEach(function(button) {
|
||||||
|
button.addEventListener('click', function() { window.history.back(); });
|
||||||
|
});
|
||||||
|
var elements = formElements();
|
||||||
|
elements.name.addEventListener('input', function(event) { onWsNameInput(event.target.value); });
|
||||||
|
elements.slug.addEventListener('input', function(event) { onWsSlugInput(event.target.value); });
|
||||||
|
elements.submit.addEventListener('click', submitForm);
|
||||||
|
|
||||||
var exportButton = document.getElementById('export-workspace-btn');
|
var exportButton = document.getElementById('export-workspace-btn');
|
||||||
if (exportButton) {
|
if (exportButton) {
|
||||||
exportButton.addEventListener('click', exportWorkspaceSnapshot);
|
exportButton.addEventListener('click', exportWorkspaceSnapshot);
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
|
||||||
|
|
||||||
location = / {
|
location = / {
|
||||||
try_files /index.html =404;
|
try_files /index.html =404;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+159
-129
@@ -6,18 +6,20 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "crank-ui",
|
"name": "crank-ui",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alpinejs": "3.15.9",
|
"@fontsource/inter": "5.2.8",
|
||||||
"js-yaml": "4.1.1"
|
"@fontsource/jetbrains-mono": "5.2.8",
|
||||||
|
"alpinejs": "3.15.12",
|
||||||
|
"js-yaml": "5.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "1.61.1",
|
||||||
"esbuild": "^0.28.0"
|
"esbuild": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -32,9 +34,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -49,9 +51,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -66,9 +68,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -83,9 +85,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -100,9 +102,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -117,9 +119,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -134,9 +136,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -151,9 +153,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -168,9 +170,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -185,9 +187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -202,9 +204,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -219,9 +221,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -236,9 +238,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -253,9 +255,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -270,9 +272,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -287,9 +289,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -304,9 +306,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -321,9 +323,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -338,9 +340,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -355,9 +357,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -372,9 +374,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -389,9 +391,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -406,9 +408,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -423,9 +425,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -440,9 +442,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -456,14 +458,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/inter": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/jetbrains-mono": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.59.1",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.59.1"
|
"playwright": "1.61.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -488,9 +508,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/alpinejs": {
|
"node_modules/alpinejs": {
|
||||||
"version": "3.15.9",
|
"version": "3.15.12",
|
||||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.9.tgz",
|
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
|
||||||
"integrity": "sha512-O30m8Tw/aARbLXmeTnISAFgrNm0K71PT7bZy/1NgRqFD36QGb34VJ4a6WBL1iIO/bofN+LkIkKLikUTkfPL2wQ==",
|
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "~3.1.1"
|
"@vue/reactivity": "~3.1.1"
|
||||||
@@ -503,9 +523,9 @@
|
|||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -516,32 +536,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.28.0",
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
"@esbuild/android-arm": "0.28.0",
|
"@esbuild/android-arm": "0.28.1",
|
||||||
"@esbuild/android-arm64": "0.28.0",
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
"@esbuild/android-x64": "0.28.0",
|
"@esbuild/android-x64": "0.28.1",
|
||||||
"@esbuild/darwin-arm64": "0.28.0",
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
"@esbuild/darwin-x64": "0.28.0",
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
"@esbuild/freebsd-arm64": "0.28.0",
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
"@esbuild/freebsd-x64": "0.28.0",
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
"@esbuild/linux-arm": "0.28.0",
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
"@esbuild/linux-arm64": "0.28.0",
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
"@esbuild/linux-ia32": "0.28.0",
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
"@esbuild/linux-loong64": "0.28.0",
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
"@esbuild/linux-mips64el": "0.28.0",
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
"@esbuild/linux-ppc64": "0.28.0",
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
"@esbuild/linux-riscv64": "0.28.0",
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
"@esbuild/linux-s390x": "0.28.0",
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
"@esbuild/linux-x64": "0.28.0",
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
"@esbuild/netbsd-arm64": "0.28.0",
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
"@esbuild/netbsd-x64": "0.28.0",
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
"@esbuild/openbsd-arm64": "0.28.0",
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
"@esbuild/openbsd-x64": "0.28.0",
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
"@esbuild/openharmony-arm64": "0.28.0",
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
"@esbuild/sunos-x64": "0.28.0",
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
"@esbuild/win32-arm64": "0.28.0",
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
"@esbuild/win32-ia32": "0.28.0",
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
"@esbuild/win32-x64": "0.28.0"
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -560,25 +580,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.59.1",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.59.1"
|
"playwright-core": "1.61.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -591,9 +621,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.59.1",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
"e2e:headed": "playwright test --headed"
|
"e2e:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alpinejs": "3.15.9",
|
"@fontsource/inter": "5.2.8",
|
||||||
"js-yaml": "4.1.1"
|
"@fontsource/jetbrains-mono": "5.2.8",
|
||||||
|
"alpinejs": "3.15.12",
|
||||||
|
"js-yaml": "5.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "1.61.1",
|
||||||
"esbuild": "^0.28.0"
|
"esbuild": "0.28.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const BRAND_IMAGE_PATHS = [
|
|||||||
path.join(ROOT_DIR, 'crank-community.png'),
|
path.join(ROOT_DIR, 'crank-community.png'),
|
||||||
path.resolve(ROOT_DIR, '..', '..', 'crank-community.png'),
|
path.resolve(ROOT_DIR, '..', '..', 'crank-community.png'),
|
||||||
];
|
];
|
||||||
|
const FONT_FILES = [
|
||||||
|
['@fontsource/inter', 'inter', ['400', '500', '600', '700']],
|
||||||
|
['@fontsource/jetbrains-mono', 'jetbrains-mono', ['400', '500']],
|
||||||
|
];
|
||||||
|
|
||||||
const BUNDLES = {
|
const BUNDLES = {
|
||||||
'protected-core': {
|
'protected-core': {
|
||||||
@@ -89,7 +93,7 @@ const BUNDLES = {
|
|||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
files: [
|
files: [
|
||||||
'node_modules/js-yaml/dist/js-yaml.min.js',
|
'node_modules/js-yaml/dist/browser/js-yaml.umd.min.js',
|
||||||
'js/wizard-state.js',
|
'js/wizard-state.js',
|
||||||
'js/wizard-shell.js',
|
'js/wizard-shell.js',
|
||||||
'js/wizard-upstreams.js',
|
'js/wizard-upstreams.js',
|
||||||
@@ -136,6 +140,20 @@ function copyDirectory(source, destination) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyFonts() {
|
||||||
|
FONT_FILES.forEach(function([packageName, family, weights]) {
|
||||||
|
weights.forEach(function(weight) {
|
||||||
|
['latin', 'cyrillic'].forEach(function(subset) {
|
||||||
|
var fileName = `${family}-${subset}-${weight}-normal.woff2`;
|
||||||
|
copyFile(
|
||||||
|
path.join(ROOT_DIR, 'node_modules', packageName, 'files', fileName),
|
||||||
|
path.join(DIST_DIR, 'fonts', fileName)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readSource(relativePath) {
|
function readSource(relativePath) {
|
||||||
return fs.readFileSync(sourcePath(relativePath), 'utf8');
|
return fs.readFileSync(sourcePath(relativePath), 'utf8');
|
||||||
}
|
}
|
||||||
@@ -191,6 +209,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
copyDirectory(path.join(ROOT_DIR, 'css'), path.join(DIST_DIR, 'css'));
|
copyDirectory(path.join(ROOT_DIR, 'css'), path.join(DIST_DIR, 'css'));
|
||||||
|
copyFonts();
|
||||||
copyDirectory(path.join(ROOT_DIR, 'data'), path.join(DIST_DIR, 'data'));
|
copyDirectory(path.join(ROOT_DIR, 'data'), path.join(DIST_DIR, 'data'));
|
||||||
ensureDirectory(path.join(DIST_DIR, 'html', 'wizard'));
|
ensureDirectory(path.join(DIST_DIR, 'html', 'wizard'));
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -119,8 +119,18 @@ function proxyRequest(request, response) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
(proxyResponse) => {
|
(proxyResponse) => {
|
||||||
|
if (response.destroyed || response.writableEnded) {
|
||||||
|
proxyResponse.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
||||||
pipeline(proxyResponse, response, () => {});
|
proxyResponse.on('error', function() {
|
||||||
|
if (!response.destroyed) response.destroy();
|
||||||
|
});
|
||||||
|
response.on('close', function() {
|
||||||
|
if (!proxyResponse.destroyed) proxyResponse.destroy();
|
||||||
|
});
|
||||||
|
proxyResponse.pipe(response);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { getCurrentWorkspace, login, localized } = require('./helpers');
|
const { getCurrentWorkspace, login, localized } = require('./helpers');
|
||||||
|
|
||||||
|
test('mobile wizard progress connector crosses the indicator centers', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 720, height: 900 });
|
||||||
|
await login(page);
|
||||||
|
await page.goto('/wizard/');
|
||||||
|
|
||||||
|
const geometry = await page.locator('.steps-list').evaluate((list) => {
|
||||||
|
const indicators = [...list.querySelectorAll('.step-indicator')];
|
||||||
|
const first = indicators[0].getBoundingClientRect();
|
||||||
|
const last = indicators.at(-1).getBoundingClientRect();
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
const line = getComputedStyle(list, '::before');
|
||||||
|
return {
|
||||||
|
leftDelta: Math.abs(listRect.left + Number.parseFloat(line.left) - (first.left + first.width / 2)),
|
||||||
|
rightDelta: Math.abs(listRect.right - Number.parseFloat(line.right) - (last.left + last.width / 2)),
|
||||||
|
topDelta: Math.abs(listRect.top + Number.parseFloat(line.top) - (first.top + first.height / 2)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(geometry.leftDelta).toBeLessThan(1);
|
||||||
|
expect(geometry.rightDelta).toBeLessThan(1);
|
||||||
|
expect(geometry.topDelta).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto('/wizard/');
|
await page.goto('/wizard/');
|
||||||
@@ -553,8 +576,6 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
|
|||||||
approval_policy: {
|
approval_policy: {
|
||||||
required: true,
|
required: true,
|
||||||
risk_level: 'financial',
|
risk_level: 'financial',
|
||||||
confirmation_title: 'Подтвердите обмен валюты',
|
|
||||||
confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.',
|
|
||||||
ttl_seconds: 180,
|
ttl_seconds: 180,
|
||||||
show_payload_preview: true,
|
show_payload_preview: true,
|
||||||
payload_preview_mode: 'masked_json',
|
payload_preview_mode: 'masked_json',
|
||||||
@@ -777,8 +798,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
|||||||
approval_policy: {
|
approval_policy: {
|
||||||
required: true,
|
required: true,
|
||||||
risk_level: 'financial',
|
risk_level: 'financial',
|
||||||
confirmation_title: 'Подтвердите обмен валюты',
|
|
||||||
confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.',
|
|
||||||
ttl_seconds: 180,
|
ttl_seconds: 180,
|
||||||
show_payload_preview: true,
|
show_payload_preview: true,
|
||||||
payload_preview_mode: 'masked_json',
|
payload_preview_mode: 'masked_json',
|
||||||
@@ -828,14 +847,13 @@ 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 page.evaluate(() => window.CrankWizardShell.doGoToStep(3));
|
||||||
await expect(page.locator('#approval-required')).toBeChecked();
|
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-config-fields')).toBeVisible();
|
||||||
await expect(page.locator('#approval-risk-level')).toHaveValue('financial');
|
await expect(page.locator('#approval-mode')).toHaveValue('custom');
|
||||||
|
await expect(page.locator('#approval-risk-level')).toHaveCount(0);
|
||||||
await expect(page.locator('#approval-ttl-seconds')).toHaveValue('180');
|
await expect(page.locator('#approval-ttl-seconds')).toHaveValue('180');
|
||||||
await expect(page.locator('#approval-title')).toHaveValue('Подтвердите обмен валюты');
|
await expect(page.locator('#approval-payload-preview-mode')).toHaveCount(0);
|
||||||
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();
|
||||||
|
|
||||||
@@ -863,12 +881,12 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
|||||||
streaming: null,
|
streaming: null,
|
||||||
approval_policy: {
|
approval_policy: {
|
||||||
required: true,
|
required: true,
|
||||||
risk_level: 'financial',
|
mode: 'custom',
|
||||||
confirmation_title: 'Подтвердите обмен валюты',
|
risk_level: 'normal',
|
||||||
confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.',
|
|
||||||
ttl_seconds: 180,
|
ttl_seconds: 180,
|
||||||
show_payload_preview: true,
|
show_payload_preview: true,
|
||||||
payload_preview_mode: 'masked_json',
|
payload_preview_mode: 'summary',
|
||||||
|
elicitation_message: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(updatePayload.tool_description).toEqual({
|
expect(updatePayload.tool_description).toEqual({
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
use std::{collections::BTreeMap, time::Duration};
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
env, io,
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||||
|
sync::Arc,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use crank_core::{HttpMethod, RestTarget};
|
use crank_core::{HttpMethod, RestTarget};
|
||||||
|
use futures_util::StreamExt;
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
Client,
|
Client,
|
||||||
|
dns::{Addrs, Name, Resolve, Resolving},
|
||||||
header::{HeaderMap, HeaderName, HeaderValue},
|
header::{HeaderMap, HeaderName, HeaderValue},
|
||||||
|
redirect,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -11,9 +20,19 @@ use crate::{RestAdapterError, RestRequest, RestResponse};
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RestAdapter {
|
pub struct RestAdapter {
|
||||||
client: Client,
|
client: Result<Client, Arc<str>>,
|
||||||
|
policy: OutboundHttpPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct OutboundHttpPolicy {
|
||||||
|
allowed_hosts: Vec<String>,
|
||||||
|
denied_hosts: Vec<String>,
|
||||||
|
max_response_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
|
||||||
impl Default for RestAdapter {
|
impl Default for RestAdapter {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -22,9 +41,25 @@ impl Default for RestAdapter {
|
|||||||
|
|
||||||
impl RestAdapter {
|
impl RestAdapter {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self::with_policy(OutboundHttpPolicy::default())
|
||||||
client: Client::new(),
|
}
|
||||||
}
|
|
||||||
|
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||||
|
Ok(Self::with_policy(OutboundHttpPolicy::from_env()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_policy(policy: OutboundHttpPolicy) -> Self {
|
||||||
|
let resolver = Arc::new(PolicyDnsResolver {
|
||||||
|
policy: policy.clone(),
|
||||||
|
});
|
||||||
|
let client = Client::builder()
|
||||||
|
.redirect(redirect::Policy::none())
|
||||||
|
.no_proxy()
|
||||||
|
.dns_resolver(resolver)
|
||||||
|
.build()
|
||||||
|
.map_err(|error| Arc::<str>::from(error.to_string()));
|
||||||
|
|
||||||
|
Self { client, policy }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
@@ -33,9 +68,15 @@ impl RestAdapter {
|
|||||||
request: &RestRequest,
|
request: &RestRequest,
|
||||||
) -> Result<RestResponse, RestAdapterError> {
|
) -> Result<RestResponse, RestAdapterError> {
|
||||||
let url = build_url(target, request)?;
|
let url = build_url(target, request)?;
|
||||||
|
self.policy.validate_url(&url)?;
|
||||||
let headers = build_headers(target, request)?;
|
let headers = build_headers(target, request)?;
|
||||||
let mut builder = self
|
let client =
|
||||||
.client
|
self.client
|
||||||
|
.as_ref()
|
||||||
|
.map_err(|details| RestAdapterError::InvalidConfiguration {
|
||||||
|
details: details.to_string(),
|
||||||
|
})?;
|
||||||
|
let mut builder = client
|
||||||
.request(to_reqwest_method(target.method), url)
|
.request(to_reqwest_method(target.method), url)
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.timeout(Duration::from_millis(request.timeout_ms));
|
.timeout(Duration::from_millis(request.timeout_ms));
|
||||||
@@ -47,7 +88,7 @@ impl RestAdapter {
|
|||||||
let response = builder.send().await?;
|
let response = builder.send().await?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let headers = normalize_headers(response.headers());
|
let headers = normalize_headers(response.headers());
|
||||||
let body = decode_body(response).await?;
|
let body = decode_body(response, self.policy.max_response_bytes).await?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(RestAdapterError::UnexpectedStatus {
|
return Err(RestAdapterError::UnexpectedStatus {
|
||||||
@@ -64,6 +105,254 @@ impl RestAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for OutboundHttpPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
allowed_hosts: Vec::new(),
|
||||||
|
denied_hosts: Vec::new(),
|
||||||
|
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundHttpPolicy {
|
||||||
|
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||||
|
let max_response_bytes = match env::var("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") {
|
||||||
|
Ok(value) => {
|
||||||
|
value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| RestAdapterError::InvalidConfiguration {
|
||||||
|
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be a positive integer"
|
||||||
|
.to_owned(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
Err(env::VarError::NotPresent) => DEFAULT_MAX_RESPONSE_BYTES,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(RestAdapterError::InvalidConfiguration {
|
||||||
|
details: error.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if max_response_bytes == 0 {
|
||||||
|
return Err(RestAdapterError::InvalidConfiguration {
|
||||||
|
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be greater than zero".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
allowed_hosts: host_patterns_from_env("CRANK_OUTBOUND_ALLOWED_HOSTS")?,
|
||||||
|
denied_hosts: host_patterns_from_env("CRANK_OUTBOUND_DENIED_HOSTS")?,
|
||||||
|
max_response_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allowing_hosts(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||||
|
Self {
|
||||||
|
allowed_hosts: hosts.into_iter().map(Into::into).collect(),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
|
||||||
|
self.max_response_bytes = max_response_bytes;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_base_url(&self, base_url: &str) -> Result<(), RestAdapterError> {
|
||||||
|
let url = reqwest::Url::parse(base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||||
|
url: base_url.to_owned(),
|
||||||
|
})?;
|
||||||
|
self.validate_url(&url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_url(&self, url: &reqwest::Url) -> Result<(), RestAdapterError> {
|
||||||
|
if !matches!(url.scheme(), "http" | "https")
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
{
|
||||||
|
return Err(RestAdapterError::TargetNotAllowed {
|
||||||
|
target: url.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
|
||||||
|
target: url.to_string(),
|
||||||
|
})?;
|
||||||
|
self.validate_host(host)?;
|
||||||
|
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
|
||||||
|
return Err(RestAdapterError::TargetNotAllowed {
|
||||||
|
target: host.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Ok(address) = host.parse::<IpAddr>()
|
||||||
|
&& !self.is_explicitly_allowed(host)
|
||||||
|
&& !is_public_ip(address)
|
||||||
|
{
|
||||||
|
return Err(RestAdapterError::TargetNotAllowed {
|
||||||
|
target: host.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_host(&self, host: &str) -> Result<(), RestAdapterError> {
|
||||||
|
let host = normalize_host(host);
|
||||||
|
let denied = self
|
||||||
|
.denied_hosts
|
||||||
|
.iter()
|
||||||
|
.any(|pattern| host_matches(pattern, &host));
|
||||||
|
if denied {
|
||||||
|
return Err(RestAdapterError::TargetNotAllowed { target: host });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_explicitly_allowed(&self, host: &str) -> bool {
|
||||||
|
let host = normalize_host(host);
|
||||||
|
self.allowed_hosts
|
||||||
|
.iter()
|
||||||
|
.any(|pattern| host_matches(pattern, &host))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PolicyDnsResolver {
|
||||||
|
policy: OutboundHttpPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resolve for PolicyDnsResolver {
|
||||||
|
fn resolve(&self, name: Name) -> Resolving {
|
||||||
|
let host = normalize_host(name.as_str());
|
||||||
|
let policy = self.policy.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
policy
|
||||||
|
.validate_host(&host)
|
||||||
|
.map_err(|error| boxed_io_error(error.to_string()))?;
|
||||||
|
let explicitly_allowed = policy.is_explicitly_allowed(&host);
|
||||||
|
let resolved = tokio::net::lookup_host((host.as_str(), 0))
|
||||||
|
.await
|
||||||
|
.map_err(|error| Box::new(error) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||||
|
let addresses = resolved
|
||||||
|
.filter(|address| explicitly_allowed || is_public_ip(address.ip()))
|
||||||
|
.collect::<Vec<SocketAddr>>();
|
||||||
|
if addresses.is_empty() {
|
||||||
|
return Err(boxed_io_error(format!(
|
||||||
|
"outbound target {host} did not resolve to an allowed address"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boxed_io_error(message: String) -> Box<dyn std::error::Error + Send + Sync> {
|
||||||
|
Box::new(io::Error::new(io::ErrorKind::PermissionDenied, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_patterns_from_env(name: &str) -> Result<Vec<String>, RestAdapterError> {
|
||||||
|
let value = match env::var(name) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(env::VarError::NotPresent) => return Ok(Vec::new()),
|
||||||
|
Err(error) => {
|
||||||
|
return Err(RestAdapterError::InvalidConfiguration {
|
||||||
|
details: error.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| {
|
||||||
|
let wildcard = value.starts_with("*.");
|
||||||
|
let normalized = normalize_host(value.trim_start_matches("*."));
|
||||||
|
let valid_ip = !wildcard && normalized.parse::<IpAddr>().is_ok();
|
||||||
|
if normalized.is_empty()
|
||||||
|
|| normalized.contains('/')
|
||||||
|
|| (!valid_ip && normalized.contains(':'))
|
||||||
|
|| (wildcard && normalized.parse::<IpAddr>().is_ok())
|
||||||
|
{
|
||||||
|
return Err(RestAdapterError::InvalidConfiguration {
|
||||||
|
details: format!("{name} contains an invalid host pattern: {value}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(if wildcard {
|
||||||
|
format!("*.{normalized}")
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_host(host: &str) -> String {
|
||||||
|
host.trim()
|
||||||
|
.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_local_hostname(host: &str) -> bool {
|
||||||
|
let host = normalize_host(host);
|
||||||
|
host == "localhost" || host.ends_with(".localhost")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_matches(pattern: &str, host: &str) -> bool {
|
||||||
|
pattern.strip_prefix("*.").map_or_else(
|
||||||
|
|| pattern == host,
|
||||||
|
|suffix| host != suffix && host.ends_with(&format!(".{suffix}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_ip(address: IpAddr) -> bool {
|
||||||
|
match address {
|
||||||
|
IpAddr::V4(address) => is_public_ipv4(address),
|
||||||
|
IpAddr::V6(address) => is_public_ipv6(address),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_ipv4(address: Ipv4Addr) -> bool {
|
||||||
|
let octets = address.octets();
|
||||||
|
!(address.is_private()
|
||||||
|
|| address.is_loopback()
|
||||||
|
|| address.is_link_local()
|
||||||
|
|| address.is_broadcast()
|
||||||
|
|| address.is_documentation()
|
||||||
|
|| address.is_unspecified()
|
||||||
|
|| address.is_multicast()
|
||||||
|
|| octets[0] == 0
|
||||||
|
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|
||||||
|
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|
||||||
|
|| (octets[0] == 198 && (18..=19).contains(&octets[1]))
|
||||||
|
|| octets[0] >= 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_ipv6(address: Ipv6Addr) -> bool {
|
||||||
|
let segments = address.segments();
|
||||||
|
if let Some(address) = address.to_ipv4_mapped() {
|
||||||
|
return is_public_ipv4(address);
|
||||||
|
}
|
||||||
|
if segments[..6].iter().all(|segment| *segment == 0) {
|
||||||
|
let [a, b] = segments[6].to_be_bytes();
|
||||||
|
let [c, d] = segments[7].to_be_bytes();
|
||||||
|
return is_public_ipv4(Ipv4Addr::new(a, b, c, d));
|
||||||
|
}
|
||||||
|
!(address.is_unspecified()
|
||||||
|
|| address.is_loopback()
|
||||||
|
|| address.is_multicast()
|
||||||
|
|| (segments[0] & 0xfe00) == 0xfc00
|
||||||
|
|| (segments[0] & 0xffc0) == 0xfe80
|
||||||
|
|| (segments[0] & 0xffc0) == 0xfec0
|
||||||
|
|| (segments[0] == 0x0064
|
||||||
|
&& segments[1] == 0xff9b
|
||||||
|
&& segments[2..6].iter().all(|segment| *segment == 0))
|
||||||
|
|| (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1)
|
||||||
|
|| segments[0] == 0x2002
|
||||||
|
|| (segments[0] == 0x2001 && matches!(segments[1], 0 | 0x0db8)))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
||||||
let base_url =
|
let base_url =
|
||||||
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||||
@@ -127,8 +416,29 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn decode_body(response: reqwest::Response) -> Result<Value, RestAdapterError> {
|
async fn decode_body(
|
||||||
let bytes = response.bytes().await?;
|
response: reqwest::Response,
|
||||||
|
max_response_bytes: usize,
|
||||||
|
) -> Result<Value, RestAdapterError> {
|
||||||
|
if response
|
||||||
|
.content_length()
|
||||||
|
.is_some_and(|length| length > max_response_bytes as u64)
|
||||||
|
{
|
||||||
|
return Err(RestAdapterError::ResponseTooLarge {
|
||||||
|
limit_bytes: max_response_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk?;
|
||||||
|
if bytes.len().saturating_add(chunk.len()) > max_response_bytes {
|
||||||
|
return Err(RestAdapterError::ResponseTooLarge {
|
||||||
|
limit_bytes: max_response_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Ok(Value::Null);
|
return Ok(Value::Null);
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ pub enum RestAdapterError {
|
|||||||
InvalidHeaderName { header: String },
|
InvalidHeaderName { header: String },
|
||||||
#[error("invalid header value for {header}")]
|
#[error("invalid header value for {header}")]
|
||||||
InvalidHeaderValue { header: String },
|
InvalidHeaderValue { header: String },
|
||||||
|
#[error("outbound target is not allowed: {target}")]
|
||||||
|
TargetNotAllowed { target: String },
|
||||||
|
#[error("rest response exceeds the configured limit of {limit_bytes} bytes")]
|
||||||
|
ResponseTooLarge { limit_bytes: usize },
|
||||||
|
#[error("invalid outbound HTTP configuration: {details}")]
|
||||||
|
InvalidConfiguration { details: String },
|
||||||
#[error("request failed")]
|
#[error("request failed")]
|
||||||
Transport(#[from] reqwest::Error),
|
Transport(#[from] reqwest::Error),
|
||||||
#[error("sse collection window expired before stream completed")]
|
#[error("sse collection window expired before stream completed")]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crank_core::{
|
|||||||
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
|
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use client::RestAdapter;
|
pub use client::{OutboundHttpPolicy, RestAdapter};
|
||||||
pub use error::RestAdapterError;
|
pub use error::RestAdapterError;
|
||||||
pub use model::{RestRequest, RestResponse};
|
pub use model::{RestRequest, RestResponse};
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ use std::collections::BTreeMap;
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, Query},
|
extract::{Path, Query},
|
||||||
http::HeaderMap,
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::Redirect,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use crank_adapter_rest::{RestAdapter, RestAdapterError, RestRequest};
|
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||||
use crank_core::{HttpMethod, RestTarget};
|
use crank_core::{HttpMethod, RestTarget};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -14,7 +15,7 @@ use tokio::net::TcpListener;
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn executes_rest_request_and_normalizes_json_response() {
|
async fn executes_rest_request_and_normalizes_json_response() {
|
||||||
let base_url = spawn_test_server().await;
|
let base_url = spawn_test_server().await;
|
||||||
let adapter = RestAdapter::new();
|
let adapter = test_adapter();
|
||||||
let target = RestTarget {
|
let target = RestTarget {
|
||||||
base_url,
|
base_url,
|
||||||
method: HttpMethod::Post,
|
method: HttpMethod::Post,
|
||||||
@@ -47,7 +48,7 @@ async fn executes_rest_request_and_normalizes_json_response() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_unexpected_status_with_normalized_body() {
|
async fn returns_unexpected_status_with_normalized_body() {
|
||||||
let base_url = spawn_test_server().await;
|
let base_url = spawn_test_server().await;
|
||||||
let adapter = RestAdapter::new();
|
let adapter = test_adapter();
|
||||||
let target = RestTarget {
|
let target = RestTarget {
|
||||||
base_url,
|
base_url,
|
||||||
method: HttpMethod::Get,
|
method: HttpMethod::Get,
|
||||||
@@ -73,10 +74,94 @@ async fn returns_unexpected_status_with_normalized_body() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_private_targets_by_default() {
|
||||||
|
let policy = OutboundHttpPolicy::default();
|
||||||
|
|
||||||
|
let error = policy
|
||||||
|
.validate_base_url("http://127.0.0.1:8080")
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_explicit_private_ipv4_and_ipv6_targets() {
|
||||||
|
let policy = OutboundHttpPolicy::allowing_hosts(["192.168.1.10", "::1"]);
|
||||||
|
|
||||||
|
assert!(policy.validate_base_url("http://192.168.1.10:8080").is_ok());
|
||||||
|
assert!(policy.validate_base_url("http://[::1]:8080").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_follow_redirects() {
|
||||||
|
let base_url = spawn_test_server().await;
|
||||||
|
let adapter = test_adapter();
|
||||||
|
let target = RestTarget {
|
||||||
|
base_url,
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
path_template: "/redirect".to_owned(),
|
||||||
|
static_headers: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.execute(&target, &empty_request())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
RestAdapterError::UnexpectedStatus { status: 303, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_responses_over_the_configured_limit() {
|
||||||
|
let base_url = spawn_test_server().await;
|
||||||
|
let adapter = RestAdapter::with_policy(
|
||||||
|
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_response_bytes(8),
|
||||||
|
);
|
||||||
|
let target = RestTarget {
|
||||||
|
base_url,
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
path_template: "/large".to_owned(),
|
||||||
|
static_headers: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.execute(&target, &empty_request())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
RestAdapterError::ResponseTooLarge { limit_bytes: 8 }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_request() -> RestRequest {
|
||||||
|
RestRequest {
|
||||||
|
path_params: BTreeMap::new(),
|
||||||
|
query_params: BTreeMap::new(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: None,
|
||||||
|
timeout_ms: 1_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_adapter() -> RestAdapter {
|
||||||
|
RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]))
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_test_server() -> String {
|
async fn spawn_test_server() -> String {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/users/{user_id}", post(create_user))
|
.route("/users/{user_id}", post(create_user))
|
||||||
.route("/fail", get(fail));
|
.route("/fail", get(fail))
|
||||||
|
.route("/redirect", get(|| async { Redirect::to("/large") }))
|
||||||
|
.route(
|
||||||
|
"/large",
|
||||||
|
get(|| async { "response larger than eight bytes" }),
|
||||||
|
);
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let address = listener.local_addr().unwrap();
|
let address = listener.local_addr().unwrap();
|
||||||
|
|
||||||
@@ -113,7 +198,7 @@ async fn create_user(
|
|||||||
|
|
||||||
async fn fail() -> (axum::http::StatusCode, Json<Value>) {
|
async fn fail() -> (axum::http::StatusCode, Json<Value>) {
|
||||||
(
|
(
|
||||||
axum::http::StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
Json(json!({ "error": "upstream failed" })),
|
Json(json!({ "error": "upstream failed" })),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use crank_core::UserSessionId;
|
use crank_core::UserSessionId;
|
||||||
use rand::RngCore;
|
use rand::RngExt;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ pub enum SessionCookieError {
|
|||||||
pub fn create_session_cookie(session_ttl_hours: i64) -> Result<SessionCookie, SessionCookieError> {
|
pub fn create_session_cookie(session_ttl_hours: i64) -> Result<SessionCookie, SessionCookieError> {
|
||||||
let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple()));
|
let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple()));
|
||||||
let mut secret_bytes = [0_u8; 32];
|
let mut secret_bytes = [0_u8; 32];
|
||||||
rand::thread_rng().fill_bytes(&mut secret_bytes);
|
rand::rng().fill(&mut secret_bytes);
|
||||||
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
||||||
let expires_at = OffsetDateTime::now_utc()
|
let expires_at = OffsetDateTime::now_utc()
|
||||||
.checked_add(Duration::hours(session_ttl_hours))
|
.checked_add(Duration::hours(session_ttl_hours))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ crank-registry = { path = "../crank-registry" }
|
|||||||
crank-runtime = { path = "../crank-runtime" }
|
crank-runtime = { path = "../crank-runtime" }
|
||||||
crank-schema = { path = "../crank-schema" }
|
crank-schema = { path = "../crank-schema" }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ use axum::{
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
|
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
|
||||||
InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus,
|
InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus,
|
||||||
PlatformApiKeyScope, SecretId,
|
OperationApprovalMode, PlatformApiKeyScope, SecretId,
|
||||||
};
|
};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
CreateApprovalRequest, CreateInvocationLogRequest, DecideApprovalRequest, PostgresRegistry,
|
CreateApprovalRequest, CreateInvocationLogRequest, DecideApprovalRequest,
|
||||||
PublishedAgentTool,
|
ExpireApprovalRequest, PostgresRegistry, PublishedAgentTool,
|
||||||
};
|
};
|
||||||
use crank_runtime::{
|
use crank_runtime::{
|
||||||
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
|
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
|
||||||
@@ -29,13 +29,14 @@ use futures_util::stream;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tracing::info;
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
access::{
|
access::{
|
||||||
credential_allows_security_level, require_approval_access, require_machine_access,
|
credential_allows_security_level, require_approval_access, require_machine_access,
|
||||||
serialize_machine_access_mode, serialize_security_level,
|
serialize_machine_access_mode, serialize_security_level,
|
||||||
},
|
},
|
||||||
|
approval_execution::{execute_approved_request, spawn_approval_recovery},
|
||||||
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
|
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
|
||||||
catalog::PublishedToolCatalog,
|
catalog::PublishedToolCatalog,
|
||||||
jsonrpc::{
|
jsonrpc::{
|
||||||
@@ -65,8 +66,8 @@ const TRANSPORT_SESSION_TTL_MS: u64 = 86_400_000;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(super) struct AppState {
|
pub(super) struct AppState {
|
||||||
pub(super) registry: PostgresRegistry,
|
pub(super) registry: PostgresRegistry,
|
||||||
catalog: PublishedToolCatalog,
|
pub(super) catalog: PublishedToolCatalog,
|
||||||
runtime: RuntimeExecutor,
|
pub(super) runtime: RuntimeExecutor,
|
||||||
pub(super) api_rate_limiter: RequestRateLimiter,
|
pub(super) api_rate_limiter: RequestRateLimiter,
|
||||||
secret_crypto: SecretCrypto,
|
secret_crypto: SecretCrypto,
|
||||||
sessions: SharedSessionStore,
|
sessions: SharedSessionStore,
|
||||||
@@ -78,6 +79,8 @@ pub(super) struct AppState {
|
|||||||
struct InitializeParams {
|
struct InitializeParams {
|
||||||
#[serde(rename = "protocolVersion")]
|
#[serde(rename = "protocolVersion")]
|
||||||
protocol_version: String,
|
protocol_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
capabilities: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -129,6 +132,59 @@ pub fn build_app(
|
|||||||
coordination_store: Arc<dyn CoordinationStateStore>,
|
coordination_store: Arc<dyn CoordinationStateStore>,
|
||||||
sessions: SharedSessionStore,
|
sessions: SharedSessionStore,
|
||||||
credential_verifier: SharedMachineCredentialVerifier,
|
credential_verifier: SharedMachineCredentialVerifier,
|
||||||
|
) -> Router {
|
||||||
|
build_app_inner(
|
||||||
|
registry,
|
||||||
|
refresh_interval,
|
||||||
|
public_base_url,
|
||||||
|
secret_crypto,
|
||||||
|
runtime,
|
||||||
|
api_rate_limiter,
|
||||||
|
coordination_store,
|
||||||
|
sessions,
|
||||||
|
credential_verifier,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn build_app_with_background_workers(
|
||||||
|
registry: PostgresRegistry,
|
||||||
|
refresh_interval: Duration,
|
||||||
|
public_base_url: Option<String>,
|
||||||
|
secret_crypto: SecretCrypto,
|
||||||
|
runtime: RuntimeExecutor,
|
||||||
|
api_rate_limiter: RequestRateLimiter,
|
||||||
|
coordination_store: Arc<dyn CoordinationStateStore>,
|
||||||
|
sessions: SharedSessionStore,
|
||||||
|
credential_verifier: SharedMachineCredentialVerifier,
|
||||||
|
) -> Router {
|
||||||
|
build_app_inner(
|
||||||
|
registry,
|
||||||
|
refresh_interval,
|
||||||
|
public_base_url,
|
||||||
|
secret_crypto,
|
||||||
|
runtime,
|
||||||
|
api_rate_limiter,
|
||||||
|
coordination_store,
|
||||||
|
sessions,
|
||||||
|
credential_verifier,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_app_inner(
|
||||||
|
registry: PostgresRegistry,
|
||||||
|
refresh_interval: Duration,
|
||||||
|
public_base_url: Option<String>,
|
||||||
|
secret_crypto: SecretCrypto,
|
||||||
|
runtime: RuntimeExecutor,
|
||||||
|
api_rate_limiter: RequestRateLimiter,
|
||||||
|
coordination_store: Arc<dyn CoordinationStateStore>,
|
||||||
|
sessions: SharedSessionStore,
|
||||||
|
credential_verifier: SharedMachineCredentialVerifier,
|
||||||
|
start_background_workers: bool,
|
||||||
) -> Router {
|
) -> Router {
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
registry: registry.clone(),
|
registry: registry.clone(),
|
||||||
@@ -140,6 +196,9 @@ pub fn build_app(
|
|||||||
credential_verifier,
|
credential_verifier,
|
||||||
allowed_origins: AllowedOrigins::new(public_base_url),
|
allowed_origins: AllowedOrigins::new(public_base_url),
|
||||||
});
|
});
|
||||||
|
if start_background_workers {
|
||||||
|
spawn_approval_recovery(Arc::clone(&state));
|
||||||
|
}
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
@@ -155,6 +214,10 @@ pub fn build_app(
|
|||||||
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve",
|
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve",
|
||||||
post(approve_request),
|
post(approve_request),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}",
|
||||||
|
get(get_approval_request),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny",
|
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny",
|
||||||
post(deny_request),
|
post(deny_request),
|
||||||
@@ -213,6 +276,34 @@ async fn approve_request(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_approval_request(
|
||||||
|
Path(path): Path<ApprovalRoutePath>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let agent_path = AgentRoutePath {
|
||||||
|
workspace_slug: path.workspace_slug,
|
||||||
|
agent_slug: path.agent_slug,
|
||||||
|
};
|
||||||
|
let key = match require_approval_access(
|
||||||
|
&state,
|
||||||
|
&agent_path,
|
||||||
|
&headers,
|
||||||
|
PlatformApiKeyScope::ReadPending,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(status) => return status.into_response(),
|
||||||
|
};
|
||||||
|
let Some(agent_id) = key.api_key.agent_id.as_ref() else {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
};
|
||||||
|
let approval_id = ApprovalRequestId::new(path.approval_id);
|
||||||
|
|
||||||
|
approval_record_response(&state, &key.api_key.workspace_id, agent_id, &approval_id).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn deny_request(
|
async fn deny_request(
|
||||||
Path(path): Path<ApprovalRoutePath>,
|
Path(path): Path<ApprovalRoutePath>,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -278,6 +369,108 @@ async fn decide_approval_request(
|
|||||||
decision_note: payload.note.as_deref(),
|
decision_note: payload.note.as_deref(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(record)) if status == ApprovalRequestStatus::Approved => {
|
||||||
|
let claimed = match state
|
||||||
|
.registry
|
||||||
|
.claim_approval_request(
|
||||||
|
&record.approval.workspace_id,
|
||||||
|
&record.approval.agent_id,
|
||||||
|
&record.approval.id,
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(claimed)) => claimed,
|
||||||
|
Ok(None) => return StatusCode::CONFLICT.into_response(),
|
||||||
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
};
|
||||||
|
match execute_approved_request(&state, &agent_path, claimed).await {
|
||||||
|
Ok(record) => Json(json!(record)).into_response(),
|
||||||
|
Err(response) => response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(record)) => Json(json!(record)).into_response(),
|
||||||
|
Ok(None) => {
|
||||||
|
terminal_decision_response(&state, &key.api_key.workspace_id, agent_id, &approval_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approval_record_response(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
agent_id: &crank_core::AgentId,
|
||||||
|
approval_id: &ApprovalRequestId,
|
||||||
|
) -> Response {
|
||||||
|
match state
|
||||||
|
.registry
|
||||||
|
.get_approval_request_for_agent(workspace_id, agent_id, approval_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(record))
|
||||||
|
if record.approval.status == ApprovalRequestStatus::Pending
|
||||||
|
&& record.approval.expires_at <= OffsetDateTime::now_utc() =>
|
||||||
|
{
|
||||||
|
expire_approval_response(state, workspace_id, agent_id, approval_id).await
|
||||||
|
}
|
||||||
|
Ok(Some(record)) => Json(json!(record)).into_response(),
|
||||||
|
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn terminal_decision_response(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
agent_id: &crank_core::AgentId,
|
||||||
|
approval_id: &ApprovalRequestId,
|
||||||
|
) -> Response {
|
||||||
|
match state
|
||||||
|
.registry
|
||||||
|
.get_approval_request_for_agent(workspace_id, agent_id, approval_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(record))
|
||||||
|
if record.approval.status == ApprovalRequestStatus::Pending
|
||||||
|
&& record.approval.expires_at <= OffsetDateTime::now_utc() =>
|
||||||
|
{
|
||||||
|
expire_approval_response(state, workspace_id, agent_id, approval_id).await
|
||||||
|
}
|
||||||
|
Ok(Some(record))
|
||||||
|
if matches!(
|
||||||
|
record.approval.status,
|
||||||
|
ApprovalRequestStatus::Completed
|
||||||
|
| ApprovalRequestStatus::Failed
|
||||||
|
| ApprovalRequestStatus::Denied
|
||||||
|
| ApprovalRequestStatus::Expired
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Json(json!(record)).into_response()
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => StatusCode::CONFLICT.into_response(),
|
||||||
|
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expire_approval_response(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
agent_id: &crank_core::AgentId,
|
||||||
|
approval_id: &ApprovalRequestId,
|
||||||
|
) -> Response {
|
||||||
|
match state
|
||||||
|
.registry
|
||||||
|
.expire_approval_request(ExpireApprovalRequest {
|
||||||
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
|
approval_id,
|
||||||
|
expired_at: OffsetDateTime::now_utc(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some(record)) => Json(json!(record)).into_response(),
|
Ok(Some(record)) => Json(json!(record)).into_response(),
|
||||||
Ok(None) => StatusCode::CONFLICT.into_response(),
|
Ok(None) => StatusCode::CONFLICT.into_response(),
|
||||||
@@ -421,24 +614,23 @@ async fn mcp_post(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID) {
|
if let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID)
|
||||||
if let Ok(session_id) = session_id.to_str() {
|
&& let Ok(session_id) = session_id.to_str()
|
||||||
let session = match state.sessions.get(session_id).await {
|
{
|
||||||
Ok(session) => session,
|
let session = match state.sessions.get(session_id).await {
|
||||||
Err(_) => {
|
Ok(session) => session,
|
||||||
return with_request_id_header(
|
Err(_) => {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
return with_request_id_header(
|
||||||
&transport_request_id,
|
StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
);
|
&transport_request_id,
|
||||||
}
|
);
|
||||||
};
|
|
||||||
if let Some(session) = session {
|
|
||||||
if let Err(status) =
|
|
||||||
validate_session_protocol_version(&headers, &session.protocol_version)
|
|
||||||
{
|
|
||||||
return with_request_id_header(status.into_response(), &transport_request_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if let Some(session) = session
|
||||||
|
&& let Err(status) =
|
||||||
|
validate_session_protocol_version(&headers, &session.protocol_version)
|
||||||
|
{
|
||||||
|
return with_request_id_header(status.into_response(), &transport_request_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,7 +830,7 @@ async fn handle_tool_call(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_operation_auth(
|
pub(super) async fn resolve_operation_auth(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
workspace_id: &crank_core::WorkspaceId,
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
execution_config: &crank_core::ExecutionConfig,
|
execution_config: &crank_core::ExecutionConfig,
|
||||||
@@ -737,7 +929,7 @@ async fn handle_base_tool_call(
|
|||||||
let tool = execution.tool;
|
let tool = execution.tool;
|
||||||
let arguments = execution.arguments;
|
let arguments = execution.arguments;
|
||||||
let operation = runtime_operation(&tool);
|
let operation = runtime_operation(&tool);
|
||||||
if let Some(response) = maybe_create_pending_approval(
|
if let Some(response) = maybe_handle_approval_policy(
|
||||||
&state,
|
&state,
|
||||||
session,
|
session,
|
||||||
message,
|
message,
|
||||||
@@ -785,7 +977,7 @@ async fn handle_base_tool_call(
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let _ = persist_invocation(
|
if let Err(error) = persist_invocation(
|
||||||
&state,
|
&state,
|
||||||
&tool,
|
&tool,
|
||||||
InvocationRecord {
|
InvocationRecord {
|
||||||
@@ -801,12 +993,15 @@ async fn handle_base_tool_call(
|
|||||||
response_preview: output.clone(),
|
response_preview: output.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %error, "successful invocation log write failed");
|
||||||
|
}
|
||||||
|
|
||||||
success_tool_response(message, response_mode, &session.protocol_version, output)
|
success_tool_response(message, response_mode, &session.protocol_version, output)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = persist_invocation(
|
if let Err(log_error) = persist_invocation(
|
||||||
&state,
|
&state,
|
||||||
&tool,
|
&tool,
|
||||||
InvocationRecord {
|
InvocationRecord {
|
||||||
@@ -822,7 +1017,10 @@ async fn handle_base_tool_call(
|
|||||||
response_preview: Value::Null,
|
response_preview: Value::Null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %log_error, "failed invocation log write failed");
|
||||||
|
}
|
||||||
|
|
||||||
tool_error_response(
|
tool_error_response(
|
||||||
message,
|
message,
|
||||||
@@ -834,7 +1032,7 @@ async fn handle_base_tool_call(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn maybe_create_pending_approval(
|
async fn maybe_handle_approval_policy(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
session: &SessionState,
|
session: &SessionState,
|
||||||
message: &Value,
|
message: &Value,
|
||||||
@@ -848,6 +1046,42 @@ async fn maybe_create_pending_approval(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match policy.mode {
|
||||||
|
OperationApprovalMode::Custom => {
|
||||||
|
maybe_create_custom_pending_approval(
|
||||||
|
state,
|
||||||
|
session,
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
tool,
|
||||||
|
arguments,
|
||||||
|
transport_request_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
OperationApprovalMode::Elicitation => Some(handle_elicitation_approval(
|
||||||
|
session,
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
tool,
|
||||||
|
arguments,
|
||||||
|
policy.elicitation_message.as_deref(),
|
||||||
|
transport_request_id,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maybe_create_custom_pending_approval(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
session: &SessionState,
|
||||||
|
message: &Value,
|
||||||
|
response_mode: ResponseMode,
|
||||||
|
tool: &PublishedAgentTool,
|
||||||
|
arguments: &Value,
|
||||||
|
transport_request_id: &str,
|
||||||
|
) -> Option<Response> {
|
||||||
|
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
|
||||||
|
|
||||||
let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple()));
|
let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple()));
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
|
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
|
||||||
@@ -868,8 +1102,6 @@ async fn maybe_create_pending_approval(
|
|||||||
},
|
},
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"risk_level": policy.risk_level,
|
"risk_level": policy.risk_level,
|
||||||
"confirmation_title": policy.confirmation_title,
|
|
||||||
"confirmation_body": policy.confirmation_body_template,
|
|
||||||
"payload_preview": if policy.show_payload_preview {
|
"payload_preview": if policy.show_payload_preview {
|
||||||
arguments.clone()
|
arguments.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -884,8 +1116,6 @@ async fn maybe_create_pending_approval(
|
|||||||
operation_version: tool.operation.version,
|
operation_version: tool.operation.version,
|
||||||
status: ApprovalRequestStatus::Pending,
|
status: ApprovalRequestStatus::Pending,
|
||||||
risk_level: policy.risk_level,
|
risk_level: policy.risk_level,
|
||||||
confirmation_title: policy.confirmation_title.clone(),
|
|
||||||
confirmation_body: policy.confirmation_body_template.clone(),
|
|
||||||
request_payload: arguments.clone(),
|
request_payload: arguments.clone(),
|
||||||
response_payload: None,
|
response_payload: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -895,17 +1125,22 @@ async fn maybe_create_pending_approval(
|
|||||||
decision_note: None,
|
decision_note: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(error) = state
|
let persisted_approval = match state
|
||||||
.registry
|
.registry
|
||||||
.create_approval_request(CreateApprovalRequest {
|
.create_approval_request(CreateApprovalRequest {
|
||||||
approval: &approval,
|
approval: &approval,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Some(internal_jsonrpc_error(message, error));
|
Ok(approval) => approval,
|
||||||
}
|
Err(error) => return Some(internal_jsonrpc_error(message, error)),
|
||||||
|
};
|
||||||
|
let response_payload = persisted_approval
|
||||||
|
.approval
|
||||||
|
.response_payload
|
||||||
|
.unwrap_or(response_payload);
|
||||||
|
|
||||||
let _ = persist_invocation(
|
if let Err(error) = persist_invocation(
|
||||||
state,
|
state,
|
||||||
tool,
|
tool,
|
||||||
InvocationRecord {
|
InvocationRecord {
|
||||||
@@ -921,7 +1156,10 @@ async fn maybe_create_pending_approval(
|
|||||||
response_preview: response_payload.clone(),
|
response_preview: response_payload.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %error, "pending approval invocation log write failed");
|
||||||
|
}
|
||||||
|
|
||||||
Some(success_tool_response(
|
Some(success_tool_response(
|
||||||
message,
|
message,
|
||||||
@@ -931,6 +1169,54 @@ async fn maybe_create_pending_approval(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_elicitation_approval(
|
||||||
|
session: &SessionState,
|
||||||
|
message: &Value,
|
||||||
|
response_mode: ResponseMode,
|
||||||
|
tool: &PublishedAgentTool,
|
||||||
|
arguments: &Value,
|
||||||
|
elicitation_message: Option<&str>,
|
||||||
|
transport_request_id: &str,
|
||||||
|
) -> Response {
|
||||||
|
if !session.supports_elicitation {
|
||||||
|
return tool_error_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
generic_tool_error_contract(
|
||||||
|
"approval_elicitation_not_supported",
|
||||||
|
"operation requires MCP Elicitation, but the MCP client did not advertise elicitation capability",
|
||||||
|
transport_request_id,
|
||||||
|
false,
|
||||||
|
Some(
|
||||||
|
"Выберите Custom MCP Approval или подключите MCP-клиент с поддержкой elicitation.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_preview = tool
|
||||||
|
.operation
|
||||||
|
.execution_config
|
||||||
|
.approval_policy
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|policy| policy.show_payload_preview.then(|| arguments.clone()))
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
success_tool_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
json!({
|
||||||
|
"status": "elicitation_required",
|
||||||
|
"message": elicitation_message.unwrap_or("Confirm operation execution."),
|
||||||
|
"tool": tool.tool_name,
|
||||||
|
"payload_preview": payload_preview,
|
||||||
|
"note": "This MCP client advertised elicitation support. Full elicitation/create continuation is handled by compatible client integrations.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn approval_url_for(tool: &PublishedAgentTool, approval_id: &ApprovalRequestId) -> String {
|
fn approval_url_for(tool: &PublishedAgentTool, approval_id: &ApprovalRequestId) -> String {
|
||||||
format!(
|
format!(
|
||||||
"/v1/{}/{}/approvals/{}",
|
"/v1/{}/{}/approvals/{}",
|
||||||
@@ -977,12 +1263,17 @@ async fn handle_initialize(
|
|||||||
};
|
};
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
let expires_at = add_millis(now, TRANSPORT_SESSION_TTL_MS);
|
let expires_at = add_millis(now, TRANSPORT_SESSION_TTL_MS);
|
||||||
|
let supports_elicitation = initialize_params
|
||||||
|
.capabilities
|
||||||
|
.get("elicitation")
|
||||||
|
.is_some_and(Value::is_object);
|
||||||
let session_id = match state
|
let session_id = match state
|
||||||
.sessions
|
.sessions
|
||||||
.create(
|
.create(
|
||||||
protocol_version,
|
protocol_version,
|
||||||
&path.workspace_slug,
|
&path.workspace_slug,
|
||||||
&path.agent_slug,
|
&path.agent_slug,
|
||||||
|
supports_elicitation,
|
||||||
now,
|
now,
|
||||||
Some(expires_at),
|
Some(expires_at),
|
||||||
)
|
)
|
||||||
@@ -1100,7 +1391,7 @@ fn take_confirmation_token(arguments: &mut Value) -> Option<String> {
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_request_preview(
|
pub(super) fn build_request_preview(
|
||||||
runtime: &RuntimeExecutor,
|
runtime: &RuntimeExecutor,
|
||||||
operation: &RuntimeOperation,
|
operation: &RuntimeOperation,
|
||||||
arguments: &Value,
|
arguments: &Value,
|
||||||
@@ -1116,20 +1407,20 @@ fn build_request_preview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InvocationRecord<'a> {
|
pub(super) struct InvocationRecord<'a> {
|
||||||
request_id: Option<&'a str>,
|
pub(super) request_id: Option<&'a str>,
|
||||||
tool_name: &'a str,
|
pub(super) tool_name: &'a str,
|
||||||
status: InvocationStatus,
|
pub(super) status: InvocationStatus,
|
||||||
level: InvocationLevel,
|
pub(super) level: InvocationLevel,
|
||||||
message: &'a str,
|
pub(super) message: &'a str,
|
||||||
status_code: Option<u16>,
|
pub(super) status_code: Option<u16>,
|
||||||
error_kind: Option<&'a str>,
|
pub(super) error_kind: Option<&'a str>,
|
||||||
duration: Duration,
|
pub(super) duration: Duration,
|
||||||
request_preview: Value,
|
pub(super) request_preview: Value,
|
||||||
response_preview: Value,
|
pub(super) response_preview: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_invocation(
|
pub(super) async fn persist_invocation(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
tool: &PublishedAgentTool,
|
tool: &PublishedAgentTool,
|
||||||
record: InvocationRecord<'_>,
|
record: InvocationRecord<'_>,
|
||||||
@@ -1239,7 +1530,7 @@ fn resolve_generated_tool(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation {
|
pub(super) fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation {
|
||||||
let mut operation = RuntimeOperation::from(tool.operation.clone());
|
let mut operation = RuntimeOperation::from(tool.operation.clone());
|
||||||
operation.tool_name = tool.tool_name.clone();
|
operation.tool_name = tool.tool_name.clone();
|
||||||
operation.tool_description.title = tool.tool_title.clone();
|
operation.tool_description.title = tool.tool_title.clone();
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
use std::{sync::Arc, time::Instant};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use crank_core::{ApprovalRequestStatus, InvocationLevel, InvocationSource, InvocationStatus};
|
||||||
|
use crank_registry::{ApprovalRequestRecord, FinishApprovalRequest};
|
||||||
|
use crank_runtime::{RuntimeExecutionRequest, RuntimeRequestContext};
|
||||||
|
use serde_json::json;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{
|
||||||
|
AgentRoutePath, AppState, InvocationRecord, build_request_preview, persist_invocation,
|
||||||
|
resolve_operation_auth, runtime_operation,
|
||||||
|
},
|
||||||
|
tool_error::runtime_error_code,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const RECOVERY_GRACE: time::Duration = time::Duration::seconds(5);
|
||||||
|
const EXECUTION_LEASE: time::Duration = time::Duration::minutes(6);
|
||||||
|
|
||||||
|
pub(super) fn spawn_approval_recovery(state: Arc<AppState>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(RECOVERY_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
recover_approved_requests(&state).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recover_approved_requests(state: &Arc<AppState>) {
|
||||||
|
for _ in 0..32 {
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let approval = match state
|
||||||
|
.registry
|
||||||
|
.claim_next_recoverable_approval_request(
|
||||||
|
now,
|
||||||
|
now - RECOVERY_GRACE,
|
||||||
|
now - EXECUTION_LEASE,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(approval)) => approval,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(error = %error, "approval recovery query failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(path) = approval_agent_path(state, &approval).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if execute_approved_request(state, &path, approval)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
warn!("recovered approval execution did not finish");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approval_agent_path(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
approval: &ApprovalRequestRecord,
|
||||||
|
) -> Option<AgentRoutePath> {
|
||||||
|
let workspace = match state
|
||||||
|
.registry
|
||||||
|
.get_workspace(&approval.approval.workspace_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(workspace)) => workspace,
|
||||||
|
Ok(None) => return None,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(error = %error, "approval workspace lookup failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let agent = match state
|
||||||
|
.registry
|
||||||
|
.get_agent_summary(&approval.approval.workspace_id, &approval.approval.agent_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(agent)) => agent,
|
||||||
|
Ok(None) => return None,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(error = %error, "approval agent lookup failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(AgentRoutePath {
|
||||||
|
workspace_slug: workspace.workspace.slug,
|
||||||
|
agent_slug: agent.slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn execute_approved_request(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
path: &AgentRoutePath,
|
||||||
|
approval: ApprovalRequestRecord,
|
||||||
|
) -> Result<ApprovalRequestRecord, Response> {
|
||||||
|
let tools = state
|
||||||
|
.catalog
|
||||||
|
.list_tools(&path.workspace_slug, &path.agent_slug)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?;
|
||||||
|
let Some(tool) = tools.into_iter().find(|tool| {
|
||||||
|
tool.operation.id == approval.approval.operation_id
|
||||||
|
&& tool.operation.version == approval.approval.operation_version
|
||||||
|
}) else {
|
||||||
|
return finish_unavailable_approval(state, &approval).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
let operation = runtime_operation(&tool);
|
||||||
|
let request_preview = build_request_preview(
|
||||||
|
&state.runtime,
|
||||||
|
&operation,
|
||||||
|
&approval.approval.request_payload,
|
||||||
|
);
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let runtime_request_context =
|
||||||
|
RuntimeRequestContext::from_request_id(approval.approval.id.as_str().to_owned())
|
||||||
|
.with_response_cache_scope(
|
||||||
|
tool.workspace_id.as_str().to_owned(),
|
||||||
|
tool.agent_id.as_str().to_owned(),
|
||||||
|
)
|
||||||
|
.with_metering_context(
|
||||||
|
tool.workspace_id.clone(),
|
||||||
|
Some(tool.agent_id.clone()),
|
||||||
|
InvocationSource::AgentToolCall,
|
||||||
|
)
|
||||||
|
.with_approval_granted();
|
||||||
|
let resolved_auth =
|
||||||
|
resolve_operation_auth(state, &tool.workspace_id, &operation.execution_config).await;
|
||||||
|
let result = match resolved_auth {
|
||||||
|
Ok(resolved_auth) => {
|
||||||
|
state
|
||||||
|
.runtime
|
||||||
|
.execute_request(
|
||||||
|
RuntimeExecutionRequest::new(&operation, &approval.approval.request_payload)
|
||||||
|
.with_optional_auth(resolved_auth.as_ref())
|
||||||
|
.with_context(&runtime_request_context),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, response_payload, invocation_status, invocation_level, message, error_kind) =
|
||||||
|
match result {
|
||||||
|
Ok(output) => (
|
||||||
|
ApprovalRequestStatus::Completed,
|
||||||
|
output,
|
||||||
|
InvocationStatus::Ok,
|
||||||
|
InvocationLevel::Info,
|
||||||
|
"approved tool call completed",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Err(error) => (
|
||||||
|
ApprovalRequestStatus::Failed,
|
||||||
|
json!({
|
||||||
|
"error": {
|
||||||
|
"code": runtime_error_code(&error),
|
||||||
|
"message": error.to_string(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
InvocationStatus::Error,
|
||||||
|
InvocationLevel::Error,
|
||||||
|
"approved tool call failed",
|
||||||
|
Some(runtime_error_code(&error)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = persist_invocation(
|
||||||
|
state,
|
||||||
|
&tool,
|
||||||
|
InvocationRecord {
|
||||||
|
request_id: Some(approval.approval.id.as_str()),
|
||||||
|
tool_name: &tool.tool_name,
|
||||||
|
status: invocation_status,
|
||||||
|
level: invocation_level,
|
||||||
|
message,
|
||||||
|
status_code: None,
|
||||||
|
error_kind,
|
||||||
|
duration: started_at.elapsed(),
|
||||||
|
request_preview,
|
||||||
|
response_preview: response_payload.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %error, "approved invocation log write failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.registry
|
||||||
|
.finish_approval_request(FinishApprovalRequest {
|
||||||
|
workspace_id: &approval.approval.workspace_id,
|
||||||
|
agent_id: &approval.approval.agent_id,
|
||||||
|
approval_id: &approval.approval.id,
|
||||||
|
status,
|
||||||
|
response_payload: Some(response_payload),
|
||||||
|
decision_note: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||||
|
.ok_or_else(|| StatusCode::CONFLICT.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_unavailable_approval(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
approval: &ApprovalRequestRecord,
|
||||||
|
) -> Result<ApprovalRequestRecord, Response> {
|
||||||
|
state
|
||||||
|
.registry
|
||||||
|
.finish_approval_request(FinishApprovalRequest {
|
||||||
|
workspace_id: &approval.approval.workspace_id,
|
||||||
|
agent_id: &approval.approval.agent_id,
|
||||||
|
approval_id: &approval.approval.id,
|
||||||
|
status: ApprovalRequestStatus::Failed,
|
||||||
|
response_payload: Some(json!({
|
||||||
|
"error": {
|
||||||
|
"code": "approved_operation_unavailable",
|
||||||
|
"message": "the approved operation version is no longer published"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
decision_note: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||||
|
.ok_or_else(|| StatusCode::CONFLICT.into_response())
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crank_core::{CacheScope, CoordinationStateStore, CoordinationStateValue};
|
use crank_core::{CacheScope, CoordinationStateStore, CoordinationStateValue};
|
||||||
use crank_registry::{PostgresRegistry, PublishedAgentTool, RegistryError};
|
use crank_registry::{PostgresRegistry, PublishedAgentTool, RegistryError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tracing::info;
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::manifest::analyze_published_tool_catalog;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PublishedToolCatalog {
|
pub struct PublishedToolCatalog {
|
||||||
@@ -16,6 +18,7 @@ pub struct PublishedToolCatalog {
|
|||||||
refresh_interval: Duration,
|
refresh_interval: Duration,
|
||||||
coordination_store: Arc<dyn CoordinationStateStore>,
|
coordination_store: Arc<dyn CoordinationStateStore>,
|
||||||
cached: Arc<RwLock<HashMap<CatalogKey, CachedCatalog>>>,
|
cached: Arc<RwLock<HashMap<CatalogKey, CachedCatalog>>>,
|
||||||
|
refresh_locks: Arc<Mutex<HashMap<CatalogKey, Arc<Mutex<()>>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
@@ -33,6 +36,7 @@ struct CachedCatalog {
|
|||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
struct CatalogSnapshot {
|
struct CatalogSnapshot {
|
||||||
tools: Vec<PublishedAgentTool>,
|
tools: Vec<PublishedAgentTool>,
|
||||||
|
generated_at_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PublishedToolCatalog {
|
impl PublishedToolCatalog {
|
||||||
@@ -46,6 +50,7 @@ impl PublishedToolCatalog {
|
|||||||
refresh_interval,
|
refresh_interval,
|
||||||
coordination_store,
|
coordination_store,
|
||||||
cached: Arc::new(RwLock::new(HashMap::new())),
|
cached: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
refresh_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +86,33 @@ impl PublishedToolCatalog {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tools) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
|
let refresh_lock = {
|
||||||
|
let mut locks = self.refresh_locks.lock().await;
|
||||||
|
Arc::clone(
|
||||||
|
locks
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let _refresh_guard = refresh_lock.lock().await;
|
||||||
|
let still_stale = {
|
||||||
|
let guard = self.cached.read().await;
|
||||||
|
match guard.get(&key).and_then(|entry| entry.loaded_at) {
|
||||||
|
Some(loaded_at) => loaded_at.elapsed() >= self.refresh_interval,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !still_stale {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((tools, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
|
||||||
|
log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &tools);
|
||||||
let mut guard = self.cached.write().await;
|
let mut guard = self.cached.write().await;
|
||||||
guard.insert(
|
guard.insert(
|
||||||
key,
|
key,
|
||||||
CachedCatalog {
|
CachedCatalog {
|
||||||
loaded_at: Some(Instant::now()),
|
loaded_at: Instant::now().checked_sub(age),
|
||||||
tools,
|
tools,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -102,6 +128,7 @@ impl PublishedToolCatalog {
|
|||||||
Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(),
|
Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(),
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
};
|
};
|
||||||
|
log_catalog_analysis(workspace_slug, agent_slug, "postgres", &tools);
|
||||||
self.store_shared_snapshot(workspace_slug, agent_slug, &tools)
|
self.store_shared_snapshot(workspace_slug, agent_slug, &tools)
|
||||||
.await;
|
.await;
|
||||||
let mut guard = self.cached.write().await;
|
let mut guard = self.cached.write().await;
|
||||||
@@ -136,7 +163,7 @@ impl PublishedToolCatalog {
|
|||||||
&self,
|
&self,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
) -> Option<Vec<PublishedAgentTool>> {
|
) -> Option<(Vec<PublishedAgentTool>, Duration)> {
|
||||||
if self.refresh_interval.is_zero() {
|
if self.refresh_interval.is_zero() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -150,9 +177,9 @@ impl PublishedToolCatalog {
|
|||||||
Ok(value) => value?,
|
Ok(value) => value?,
|
||||||
Err(_) => return None,
|
Err(_) => return None,
|
||||||
};
|
};
|
||||||
serde_json::from_value::<CatalogSnapshot>(value.payload)
|
let snapshot = serde_json::from_value::<CatalogSnapshot>(value.payload).ok()?;
|
||||||
.ok()
|
let age = Duration::from_millis(now_unix_ms().saturating_sub(snapshot.generated_at_ms));
|
||||||
.map(|snapshot| snapshot.tools)
|
(age < self.refresh_interval).then_some((snapshot.tools, age))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn store_shared_snapshot(
|
async fn store_shared_snapshot(
|
||||||
@@ -167,6 +194,7 @@ impl PublishedToolCatalog {
|
|||||||
|
|
||||||
let payload = match serde_json::to_value(CatalogSnapshot {
|
let payload = match serde_json::to_value(CatalogSnapshot {
|
||||||
tools: tools.to_vec(),
|
tools: tools.to_vec(),
|
||||||
|
generated_at_ms: now_unix_ms(),
|
||||||
}) {
|
}) {
|
||||||
Ok(payload) => payload,
|
Ok(payload) => payload,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
@@ -184,6 +212,49 @@ impl PublishedToolCatalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_catalog_analysis(
|
||||||
|
workspace_slug: &str,
|
||||||
|
agent_slug: &str,
|
||||||
|
source: &str,
|
||||||
|
tools: &[PublishedAgentTool],
|
||||||
|
) {
|
||||||
|
let analysis = match analyze_published_tool_catalog(tools) {
|
||||||
|
Ok(analysis) => analysis,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(workspace_slug, agent_slug, source, %error, "published catalog analysis failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let warning_count = analysis
|
||||||
|
.quality
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.severity == crank_core::ToolQualitySeverity::Warning)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
workspace_slug,
|
||||||
|
agent_slug,
|
||||||
|
source,
|
||||||
|
tool_count = analysis.budget.tool_count,
|
||||||
|
serialized_bytes = analysis.budget.serialized_bytes,
|
||||||
|
estimated_context_tokens = analysis.budget.estimated_context_tokens,
|
||||||
|
largest_tool_estimated_context_tokens =
|
||||||
|
analysis.budget.largest_tool_estimated_context_tokens,
|
||||||
|
recommended_context_tokens = analysis.budget.recommended_context_tokens,
|
||||||
|
exceeds_recommended_budget = analysis.budget.exceeds_recommended_budget,
|
||||||
|
catalog_quality_warning_count = warning_count,
|
||||||
|
"published agent catalog analyzed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
impl CatalogKey {
|
impl CatalogKey {
|
||||||
fn new(workspace_slug: &str, agent_slug: &str) -> Self {
|
fn new(workspace_slug: &str, agent_slug: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod access;
|
mod access;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod approval_execution;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod jsonrpc;
|
pub mod jsonrpc;
|
||||||
@@ -9,4 +10,4 @@ pub mod session;
|
|||||||
pub mod tool_error;
|
pub mod tool_error;
|
||||||
mod transport;
|
mod transport;
|
||||||
|
|
||||||
pub use app::build_app;
|
pub use app::{build_app, build_app_with_background_workers};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use crank_core::{HttpMethod, OperationSafetyClass, OperationSafetyPolicy, Target};
|
use crank_core::{
|
||||||
|
HttpMethod, OperationSafetyClass, OperationSafetyPolicy, Target, ToolCatalogAnalysis,
|
||||||
|
ToolCatalogAnalysisError, ToolQualityCatalogTool, analyze_tool_catalog,
|
||||||
|
};
|
||||||
use crank_registry::PublishedAgentTool;
|
use crank_registry::PublishedAgentTool;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -27,6 +30,37 @@ pub fn tool_definitions(tool: &PublishedAgentTool) -> Vec<Value> {
|
|||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn analyze_published_tool_catalog(
|
||||||
|
tools: &[PublishedAgentTool],
|
||||||
|
) -> Result<ToolCatalogAnalysis, ToolCatalogAnalysisError> {
|
||||||
|
let mut quality_tools = Vec::new();
|
||||||
|
let mut definitions = Vec::new();
|
||||||
|
|
||||||
|
for tool in tools {
|
||||||
|
for definition in tool_definitions(tool) {
|
||||||
|
quality_tools.push(ToolQualityCatalogTool {
|
||||||
|
name: definition_string(&definition, "name")?,
|
||||||
|
display_name: definition_string(&definition, "title")?,
|
||||||
|
description: definition_string(&definition, "description")?,
|
||||||
|
});
|
||||||
|
definitions.push(definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
analyze_tool_catalog(&quality_tools, &definitions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn definition_string(
|
||||||
|
definition: &Value,
|
||||||
|
field: &'static str,
|
||||||
|
) -> Result<String, ToolCatalogAnalysisError> {
|
||||||
|
definition
|
||||||
|
.get(field)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.ok_or(ToolCatalogAnalysisError::DefinitionFieldMissing(field))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn tool_definition(name: &str, title: &str, description: &str, input_schema: Value) -> Value {
|
pub fn tool_definition(name: &str, title: &str, description: &str, input_schema: Value) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crank_registry::PostgresPoolConfig;
|
use crank_registry::PostgresPoolConfig;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
PgPool,
|
PgPool, Row,
|
||||||
postgres::{PgConnectOptions, PgPoolOptions},
|
postgres::{PgConnectOptions, PgPoolOptions},
|
||||||
query,
|
query,
|
||||||
};
|
};
|
||||||
@@ -17,6 +17,7 @@ pub struct SessionState {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub protocol_version: String,
|
pub protocol_version: String,
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
|
pub supports_elicitation: bool,
|
||||||
pub workspace_slug: String,
|
pub workspace_slug: String,
|
||||||
pub agent_slug: String,
|
pub agent_slug: String,
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
@@ -37,6 +38,7 @@ pub trait TransportSessionStore: Send + Sync {
|
|||||||
protocol_version: &str,
|
protocol_version: &str,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
|
supports_elicitation: bool,
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
expires_at: Option<OffsetDateTime>,
|
expires_at: Option<OffsetDateTime>,
|
||||||
) -> Result<String, SessionStoreError>;
|
) -> Result<String, SessionStoreError>;
|
||||||
@@ -100,6 +102,7 @@ impl TransportSessionStore for InMemorySessionStore {
|
|||||||
protocol_version: &str,
|
protocol_version: &str,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
|
supports_elicitation: bool,
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
expires_at: Option<OffsetDateTime>,
|
expires_at: Option<OffsetDateTime>,
|
||||||
) -> Result<String, SessionStoreError> {
|
) -> Result<String, SessionStoreError> {
|
||||||
@@ -112,6 +115,7 @@ impl TransportSessionStore for InMemorySessionStore {
|
|||||||
id: session_id.clone(),
|
id: session_id.clone(),
|
||||||
protocol_version: protocol_version.to_owned(),
|
protocol_version: protocol_version.to_owned(),
|
||||||
initialized: false,
|
initialized: false,
|
||||||
|
supports_elicitation,
|
||||||
workspace_slug: workspace_slug.to_owned(),
|
workspace_slug: workspace_slug.to_owned(),
|
||||||
agent_slug: agent_slug.to_owned(),
|
agent_slug: agent_slug.to_owned(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -169,6 +173,7 @@ impl TransportSessionStore for PostgresTransportSessionStore {
|
|||||||
protocol_version: &str,
|
protocol_version: &str,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
|
supports_elicitation: bool,
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
expires_at: Option<OffsetDateTime>,
|
expires_at: Option<OffsetDateTime>,
|
||||||
) -> Result<String, SessionStoreError> {
|
) -> Result<String, SessionStoreError> {
|
||||||
@@ -178,17 +183,19 @@ impl TransportSessionStore for PostgresTransportSessionStore {
|
|||||||
id,
|
id,
|
||||||
protocol_version,
|
protocol_version,
|
||||||
initialized,
|
initialized,
|
||||||
|
supports_elicitation,
|
||||||
workspace_slug,
|
workspace_slug,
|
||||||
agent_slug,
|
agent_slug,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
expires_at
|
expires_at
|
||||||
) values (
|
) values (
|
||||||
$1, $2, false, $3, $4, $5::timestamptz, $5::timestamptz, $6::timestamptz
|
$1, $2, false, $3, $4, $5, $6::timestamptz, $6::timestamptz, $7::timestamptz
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(&session_id)
|
.bind(&session_id)
|
||||||
.bind(protocol_version)
|
.bind(protocol_version)
|
||||||
|
.bind(supports_elicitation)
|
||||||
.bind(workspace_slug)
|
.bind(workspace_slug)
|
||||||
.bind(agent_slug)
|
.bind(agent_slug)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
@@ -203,20 +210,21 @@ impl TransportSessionStore for PostgresTransportSessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, session_id: &str) -> Result<Option<SessionState>, SessionStoreError> {
|
async fn get(&self, session_id: &str) -> Result<Option<SessionState>, SessionStoreError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query(
|
||||||
"select
|
"select
|
||||||
id,
|
id,
|
||||||
protocol_version,
|
protocol_version,
|
||||||
initialized,
|
initialized,
|
||||||
|
supports_elicitation,
|
||||||
workspace_slug,
|
workspace_slug,
|
||||||
agent_slug,
|
agent_slug,
|
||||||
created_at as \"created_at!: OffsetDateTime\",
|
created_at,
|
||||||
updated_at as \"updated_at!: OffsetDateTime\",
|
updated_at,
|
||||||
expires_at as \"expires_at: OffsetDateTime\"
|
expires_at
|
||||||
from mcp_transport_sessions
|
from mcp_transport_sessions
|
||||||
where id = $1",
|
where id = $1",
|
||||||
session_id,
|
|
||||||
)
|
)
|
||||||
|
.bind(session_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| SessionStoreError {
|
.map_err(|error| SessionStoreError {
|
||||||
@@ -224,14 +232,15 @@ impl TransportSessionStore for PostgresTransportSessionStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let Some(session) = row.map(|row| SessionState {
|
let Some(session) = row.map(|row| SessionState {
|
||||||
id: row.id,
|
id: row.get("id"),
|
||||||
protocol_version: row.protocol_version,
|
protocol_version: row.get("protocol_version"),
|
||||||
initialized: row.initialized,
|
initialized: row.get("initialized"),
|
||||||
workspace_slug: row.workspace_slug,
|
supports_elicitation: row.get("supports_elicitation"),
|
||||||
agent_slug: row.agent_slug,
|
workspace_slug: row.get("workspace_slug"),
|
||||||
created_at: row.created_at,
|
agent_slug: row.get("agent_slug"),
|
||||||
updated_at: row.updated_at,
|
created_at: row.get("created_at"),
|
||||||
expires_at: row.expires_at,
|
updated_at: row.get("updated_at"),
|
||||||
|
expires_at: row.get("expires_at"),
|
||||||
}) else {
|
}) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -291,6 +300,7 @@ async fn apply_postgres_migrations(pool: &PgPool) -> Result<(), SessionStoreErro
|
|||||||
id text primary key,
|
id text primary key,
|
||||||
protocol_version text not null,
|
protocol_version text not null,
|
||||||
initialized boolean not null default false,
|
initialized boolean not null default false,
|
||||||
|
supports_elicitation boolean not null default false,
|
||||||
workspace_slug text not null,
|
workspace_slug text not null,
|
||||||
agent_slug text not null,
|
agent_slug text not null,
|
||||||
created_at timestamptz not null,
|
created_at timestamptz not null,
|
||||||
@@ -304,6 +314,13 @@ async fn apply_postgres_migrations(pool: &PgPool) -> Result<(), SessionStoreErro
|
|||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
query("alter table mcp_transport_sessions add column if not exists supports_elicitation boolean not null default false")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| SessionStoreError {
|
||||||
|
details: error.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
query(
|
query(
|
||||||
"alter table mcp_transport_sessions add column if not exists expires_at timestamptz null",
|
"alter table mcp_transport_sessions add column if not exists expires_at timestamptz null",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use axum::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
|
use reqwest::Url;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::jsonrpc::{
|
use crate::jsonrpc::{
|
||||||
@@ -42,21 +43,44 @@ impl AllowedOrigins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_allowed(&self, origin: &str) -> bool {
|
pub(super) fn is_allowed(&self, origin: &str) -> bool {
|
||||||
if origin.starts_with("http://localhost")
|
let Some(origin) = parse_origin(origin, true) else {
|
||||||
|| origin.starts_with("http://127.0.0.1")
|
return false;
|
||||||
|| origin.starts_with("https://localhost")
|
};
|
||||||
|| origin.starts_with("https://127.0.0.1")
|
|
||||||
{
|
if is_loopback_origin(&origin) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
match &self.public_origin {
|
match &self.public_origin {
|
||||||
Some(public_origin) => public_origin == origin,
|
Some(public_origin) => public_origin == &origin.origin().ascii_serialization(),
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_loopback_origin(origin: &Url) -> bool {
|
||||||
|
matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_origin(value: &str, origin_only: bool) -> Option<Url> {
|
||||||
|
let origin = Url::parse(value).ok()?;
|
||||||
|
if !matches!(origin.scheme(), "http" | "https")
|
||||||
|
|| origin.host_str().is_none()
|
||||||
|
|| !origin.username().is_empty()
|
||||||
|
|| origin.password().is_some()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if origin_only
|
||||||
|
&& (origin.path() != "/" || origin.query().is_some() || origin.fragment().is_some())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(origin)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn validate_origin(
|
pub(super) fn validate_origin(
|
||||||
allowed_origins: &AllowedOrigins,
|
allowed_origins: &AllowedOrigins,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -298,14 +322,58 @@ pub(super) fn is_valid_request_id(value: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn extract_origin(url: &str) -> Option<String> {
|
pub(super) fn extract_origin(url: &str) -> Option<String> {
|
||||||
let mut parts = url.split('/');
|
Some(parse_origin(url, false)?.origin().ascii_serialization())
|
||||||
let scheme = parts.next()?;
|
}
|
||||||
let empty = parts.next()?;
|
|
||||||
let authority = parts.next()?;
|
|
||||||
|
|
||||||
if empty.is_empty() {
|
#[cfg(test)]
|
||||||
return Some(format!("{scheme}//{authority}"));
|
mod tests {
|
||||||
|
use super::AllowedOrigins;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_loopback_origins_with_and_without_port() {
|
||||||
|
let origins = AllowedOrigins::new(None);
|
||||||
|
|
||||||
|
assert!(origins.is_allowed("http://localhost"));
|
||||||
|
assert!(origins.is_allowed("http://localhost:3000"));
|
||||||
|
assert!(origins.is_allowed("https://127.0.0.1:8443"));
|
||||||
|
assert!(origins.is_allowed("http://[::1]:3000"));
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
#[test]
|
||||||
|
fn rejects_hosts_that_only_prefix_match_loopback() {
|
||||||
|
let origins = AllowedOrigins::new(None);
|
||||||
|
|
||||||
|
assert!(!origins.is_allowed("http://localhost.attacker.com"));
|
||||||
|
assert!(!origins.is_allowed("http://127.0.0.1.attacker.com"));
|
||||||
|
assert!(!origins.is_allowed("http://localhost@attacker.com"));
|
||||||
|
assert!(!origins.is_allowed("http://attacker.com/http://localhost"));
|
||||||
|
assert!(!origins.is_allowed("http://[::1].attacker.com"));
|
||||||
|
assert!(!origins.is_allowed("http://attacker@[::1]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_configured_public_origin_exactly() {
|
||||||
|
let origins = AllowedOrigins::new(Some("https://crank.example.com".to_owned()));
|
||||||
|
|
||||||
|
assert!(origins.is_allowed("https://crank.example.com"));
|
||||||
|
assert!(!origins.is_allowed("https://crank.example.com.attacker.com"));
|
||||||
|
assert!(!origins.is_allowed("https://evil.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_http_schemes() {
|
||||||
|
let origins = AllowedOrigins::new(None);
|
||||||
|
|
||||||
|
assert!(!origins.is_allowed("file://localhost"));
|
||||||
|
assert!(!origins.is_allowed("javascript://localhost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_values_that_are_not_origins() {
|
||||||
|
let origins = AllowedOrigins::new(None);
|
||||||
|
|
||||||
|
assert!(!origins.is_allowed("http://localhost/path"));
|
||||||
|
assert!(!origins.is_allowed("http://localhost?query=value"));
|
||||||
|
assert!(!origins.is_allowed("http://localhost#fragment"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async fn postgres_transport_sessions_survive_store_reconnect() {
|
|||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
"default",
|
"default",
|
||||||
"sales",
|
"sales",
|
||||||
|
false,
|
||||||
created_at,
|
created_at,
|
||||||
Some(created_at + time::Duration::days(30)),
|
Some(created_at + time::Duration::days(30)),
|
||||||
)
|
)
|
||||||
@@ -73,6 +74,7 @@ async fn postgres_transport_sessions_evict_expired_rows_on_read() {
|
|||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
"default",
|
"default",
|
||||||
"sales",
|
"sales",
|
||||||
|
false,
|
||||||
timestamp("2026-05-01T10:00:00Z"),
|
timestamp("2026-05-01T10:00:00Z"),
|
||||||
Some(timestamp("2026-05-01T10:00:01Z")),
|
Some(timestamp("2026-05-01T10:00:01Z")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crank_community_mcp::manifest::tool_definitions;
|
use crank_community_mcp::manifest::{analyze_published_tool_catalog, tool_definitions};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel, OperationStatus,
|
ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel, OperationStatus,
|
||||||
Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
||||||
@@ -57,6 +57,20 @@ fn marks_destructive_tools_as_two_step_confirmation_calls() {
|
|||||||
assert_eq!(definition["inputSchema"]["required"], json!(["base"]));
|
assert_eq!(definition["inputSchema"]["required"], json!(["base"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_budget_uses_the_same_definitions_as_tools_list() {
|
||||||
|
let tool = published_tool();
|
||||||
|
let definitions = tool_definitions(&tool);
|
||||||
|
let analysis = analyze_published_tool_catalog(&[tool]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(analysis.budget.tool_count, definitions.len());
|
||||||
|
assert_eq!(
|
||||||
|
analysis.budget.serialized_bytes,
|
||||||
|
serde_json::to_vec(&definitions[0]).unwrap().len()
|
||||||
|
);
|
||||||
|
assert!(!analysis.budget.exceeds_recommended_budget);
|
||||||
|
}
|
||||||
|
|
||||||
fn published_tool() -> PublishedAgentTool {
|
fn published_tool() -> PublishedAgentTool {
|
||||||
PublishedAgentTool {
|
PublishedAgentTool {
|
||||||
workspace_id: WorkspaceId::new("ws_01"),
|
workspace_id: WorkspaceId::new("ws_01"),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ async fn creates_and_reads_transport_sessions() {
|
|||||||
let created_at = timestamp("2026-05-01T10:00:00Z");
|
let created_at = timestamp("2026-05-01T10:00:00Z");
|
||||||
|
|
||||||
let session_id = store
|
let session_id = store
|
||||||
.create("2025-11-25", "default", "sales", created_at, None)
|
.create("2025-11-25", "default", "sales", false, created_at, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let session = store.get(&session_id).await.unwrap().unwrap();
|
let session = store.get(&session_id).await.unwrap().unwrap();
|
||||||
@@ -23,6 +23,7 @@ async fn creates_and_reads_transport_sessions() {
|
|||||||
assert_eq!(session.workspace_slug, "default");
|
assert_eq!(session.workspace_slug, "default");
|
||||||
assert_eq!(session.agent_slug, "sales");
|
assert_eq!(session.agent_slug, "sales");
|
||||||
assert!(!session.initialized);
|
assert!(!session.initialized);
|
||||||
|
assert!(!session.supports_elicitation);
|
||||||
assert_eq!(session.created_at, created_at);
|
assert_eq!(session.created_at, created_at);
|
||||||
assert_eq!(session.updated_at, created_at);
|
assert_eq!(session.updated_at, created_at);
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,7 @@ async fn marks_transport_sessions_initialized() {
|
|||||||
let initialized_at = timestamp("2026-05-01T10:00:05Z");
|
let initialized_at = timestamp("2026-05-01T10:00:05Z");
|
||||||
|
|
||||||
let session_id = store
|
let session_id = store
|
||||||
.create("2025-11-25", "default", "sales", created_at, None)
|
.create("2025-11-25", "default", "sales", false, created_at, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ async fn drops_expired_in_memory_transport_sessions_on_read() {
|
|||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
"default",
|
"default",
|
||||||
"sales",
|
"sales",
|
||||||
|
false,
|
||||||
created_at,
|
created_at,
|
||||||
Some(expires_at),
|
Some(expires_at),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ pub enum ApprovalRequestStatus {
|
|||||||
#[default]
|
#[default]
|
||||||
Pending,
|
Pending,
|
||||||
Approved,
|
Approved,
|
||||||
|
Executing,
|
||||||
Denied,
|
Denied,
|
||||||
Expired,
|
Expired,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -24,8 +27,6 @@ pub struct ApprovalRequest {
|
|||||||
pub operation_version: u32,
|
pub operation_version: u32,
|
||||||
pub status: ApprovalRequestStatus,
|
pub status: ApprovalRequestStatus,
|
||||||
pub risk_level: OperationApprovalRiskLevel,
|
pub risk_level: OperationApprovalRiskLevel,
|
||||||
pub confirmation_title: String,
|
|
||||||
pub confirmation_body: String,
|
|
||||||
pub request_payload: Value,
|
pub request_payload: Value,
|
||||||
pub response_payload: Option<Value>,
|
pub response_payload: Option<Value>,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
|||||||
@@ -14,18 +14,13 @@ pub enum MachineAccessMode {
|
|||||||
StaticAgentKey,
|
StaticAgentKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum OperationSecurityLevel {
|
pub enum OperationSecurityLevel {
|
||||||
|
#[default]
|
||||||
Standard,
|
Standard,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OperationSecurityLevel {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Standard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct EditionLimits {
|
pub struct EditionLimits {
|
||||||
pub max_workspaces: Option<u32>,
|
pub max_workspaces: Option<u32>,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod observability;
|
|||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod secret;
|
pub mod secret;
|
||||||
|
pub mod tool_catalog;
|
||||||
pub mod tool_quality;
|
pub mod tool_quality;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
@@ -38,18 +39,19 @@ pub mod domain {
|
|||||||
UserSessionId, WorkspaceId,
|
UserSessionId, WorkspaceId,
|
||||||
};
|
};
|
||||||
pub use crate::observability::{
|
pub use crate::observability::{
|
||||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
|
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||||
UsageRollup,
|
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
|
||||||
};
|
};
|
||||||
pub use crate::operation::{
|
pub use crate::operation::{
|
||||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode,
|
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
|
||||||
OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass,
|
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
||||||
OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy,
|
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy,
|
||||||
Samples, Target, ToolDescription, ToolExample, WizardState,
|
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};
|
||||||
|
pub use crate::tool_catalog::{ToolCatalogAnalysis, ToolCatalogBudget};
|
||||||
pub use crate::tool_quality::{
|
pub use crate::tool_quality::{
|
||||||
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||||
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||||
@@ -129,17 +131,22 @@ pub use ids::{
|
|||||||
UserSessionId, WorkspaceId,
|
UserSessionId, WorkspaceId,
|
||||||
};
|
};
|
||||||
pub use observability::{
|
pub use observability::{
|
||||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||||
|
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
|
||||||
};
|
};
|
||||||
pub use operation::{
|
pub use operation::{
|
||||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode,
|
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
|
||||||
OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass,
|
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
||||||
OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples,
|
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget,
|
||||||
Target, ToolDescription, ToolExample, WizardState,
|
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};
|
||||||
|
pub use tool_catalog::{
|
||||||
|
RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS, ToolCatalogAnalysis, ToolCatalogAnalysisError,
|
||||||
|
ToolCatalogBudget, analyze_tool_catalog,
|
||||||
|
};
|
||||||
pub use tool_quality::{
|
pub use tool_quality::{
|
||||||
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||||
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||||
|
|||||||
@@ -1,9 +1,68 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::{Map, Value, json};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::{AgentId, OperationId, WorkspaceId};
|
use crate::{AgentId, OperationId, WorkspaceId};
|
||||||
|
|
||||||
|
pub const INVOCATION_PREVIEW_MAX_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
|
pub fn sanitize_invocation_preview(value: &Value) -> Value {
|
||||||
|
let redacted = redact_sensitive_fields(value);
|
||||||
|
let Ok(encoded) = serde_json::to_vec(&redacted) else {
|
||||||
|
return Value::Null;
|
||||||
|
};
|
||||||
|
if encoded.len() <= INVOCATION_PREVIEW_MAX_BYTES {
|
||||||
|
return redacted;
|
||||||
|
}
|
||||||
|
let preview = String::from_utf8_lossy(&encoded[..INVOCATION_PREVIEW_MAX_BYTES]).into_owned();
|
||||||
|
json!({
|
||||||
|
"truncated": true,
|
||||||
|
"original_bytes": encoded.len(),
|
||||||
|
"preview": preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_sensitive_fields(value: &Value) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => Value::Object(
|
||||||
|
object
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| {
|
||||||
|
let value = if is_sensitive_key(key) {
|
||||||
|
Value::String("[REDACTED]".to_owned())
|
||||||
|
} else {
|
||||||
|
redact_sensitive_fields(value)
|
||||||
|
};
|
||||||
|
(key.clone(), value)
|
||||||
|
})
|
||||||
|
.collect::<Map<String, Value>>(),
|
||||||
|
),
|
||||||
|
Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_fields).collect()),
|
||||||
|
_ => value.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_sensitive_key(key: &str) -> bool {
|
||||||
|
let normalized = key.to_ascii_lowercase().replace('-', "_");
|
||||||
|
let compact = normalized
|
||||||
|
.chars()
|
||||||
|
.filter(char::is_ascii_alphanumeric)
|
||||||
|
.collect::<String>();
|
||||||
|
[
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|sensitive| compact.contains(sensitive))
|
||||||
|
|| ["apikey", "accesskey", "privatekey"]
|
||||||
|
.iter()
|
||||||
|
.any(|sensitive| compact.contains(sensitive))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum InvocationSource {
|
pub enum InvocationSource {
|
||||||
@@ -87,7 +146,10 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
|
||||||
use super::{InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod};
|
use super::{
|
||||||
|
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||||
|
InvocationStatus, UsagePeriod, sanitize_invocation_preview,
|
||||||
|
};
|
||||||
use crate::{AgentId, OperationId, WorkspaceId, ids::InvocationLogId};
|
use crate::{AgentId, OperationId, WorkspaceId, ids::InvocationLogId};
|
||||||
|
|
||||||
fn timestamp(value: &str) -> OffsetDateTime {
|
fn timestamp(value: &str) -> OffsetDateTime {
|
||||||
@@ -129,4 +191,33 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(value, "7d");
|
assert_eq!(value, "7d");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_sensitive_fields_recursively() {
|
||||||
|
let preview = sanitize_invocation_preview(&json!({
|
||||||
|
"authorization": "Bearer secret",
|
||||||
|
"nested": {
|
||||||
|
"api_key": "key",
|
||||||
|
"refreshToken": "token",
|
||||||
|
"client_secret_value": "secret",
|
||||||
|
"value": 42
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(preview["authorization"], "[REDACTED]");
|
||||||
|
assert_eq!(preview["nested"]["api_key"], "[REDACTED]");
|
||||||
|
assert_eq!(preview["nested"]["refreshToken"], "[REDACTED]");
|
||||||
|
assert_eq!(preview["nested"]["client_secret_value"], "[REDACTED]");
|
||||||
|
assert_eq!(preview["nested"]["value"], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncates_large_previews() {
|
||||||
|
let preview = sanitize_invocation_preview(&json!({
|
||||||
|
"body": "x".repeat(INVOCATION_PREVIEW_MAX_BYTES * 2)
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(preview["truncated"], true);
|
||||||
|
assert!(preview["original_bytes"].as_u64().unwrap() > INVOCATION_PREVIEW_MAX_BYTES as u64);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,15 +121,25 @@ pub enum OperationApprovalPayloadPreviewMode {
|
|||||||
MaskedJson,
|
MaskedJson,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum OperationApprovalMode {
|
||||||
|
#[default]
|
||||||
|
Custom,
|
||||||
|
Elicitation,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct OperationApprovalPolicy {
|
pub struct OperationApprovalPolicy {
|
||||||
pub required: bool,
|
pub required: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: OperationApprovalMode,
|
||||||
pub risk_level: OperationApprovalRiskLevel,
|
pub risk_level: OperationApprovalRiskLevel,
|
||||||
pub confirmation_title: String,
|
|
||||||
pub confirmation_body_template: String,
|
|
||||||
pub ttl_seconds: u32,
|
pub ttl_seconds: u32,
|
||||||
pub show_payload_preview: bool,
|
pub show_payload_preview: bool,
|
||||||
pub payload_preview_mode: OperationApprovalPayloadPreviewMode,
|
pub payload_preview_mode: OperationApprovalPayloadPreviewMode,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub elicitation_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::tool_quality::{
|
||||||
|
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityReport, ToolQualitySeverity,
|
||||||
|
analyze_agent_tool_catalog_quality,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS: usize = 4_096;
|
||||||
|
const ESTIMATED_TOKEN_UTF8_BYTES: usize = 3;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ToolCatalogBudget {
|
||||||
|
pub tool_count: usize,
|
||||||
|
pub serialized_bytes: usize,
|
||||||
|
pub estimated_context_tokens: usize,
|
||||||
|
pub largest_tool_estimated_context_tokens: usize,
|
||||||
|
pub recommended_context_tokens: usize,
|
||||||
|
pub exceeds_recommended_budget: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ToolCatalogAnalysis {
|
||||||
|
pub budget: ToolCatalogBudget,
|
||||||
|
pub quality: ToolQualityReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ToolCatalogAnalysisError {
|
||||||
|
#[error("tool catalog and definitions length mismatch")]
|
||||||
|
DefinitionCountMismatch,
|
||||||
|
#[error("tool catalog definition is missing string field: {0}")]
|
||||||
|
DefinitionFieldMissing(&'static str),
|
||||||
|
#[error("tool catalog definition serialization failed: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze_tool_catalog(
|
||||||
|
tools: &[ToolQualityCatalogTool],
|
||||||
|
definitions: &[Value],
|
||||||
|
) -> Result<ToolCatalogAnalysis, ToolCatalogAnalysisError> {
|
||||||
|
if tools.len() != definitions.len() {
|
||||||
|
return Err(ToolCatalogAnalysisError::DefinitionCountMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut serialized_bytes = 0usize;
|
||||||
|
let mut estimated_context_tokens = 0usize;
|
||||||
|
let mut largest_tool_estimated_context_tokens = 0usize;
|
||||||
|
for definition in definitions {
|
||||||
|
let definition_bytes = serde_json::to_vec(definition)?.len();
|
||||||
|
let definition_tokens = estimate_context_tokens(definition_bytes);
|
||||||
|
serialized_bytes = serialized_bytes.saturating_add(definition_bytes);
|
||||||
|
estimated_context_tokens = estimated_context_tokens.saturating_add(definition_tokens);
|
||||||
|
largest_tool_estimated_context_tokens =
|
||||||
|
largest_tool_estimated_context_tokens.max(definition_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exceeds_recommended_budget =
|
||||||
|
estimated_context_tokens > RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS;
|
||||||
|
let budget = ToolCatalogBudget {
|
||||||
|
tool_count: tools.len(),
|
||||||
|
serialized_bytes,
|
||||||
|
estimated_context_tokens,
|
||||||
|
largest_tool_estimated_context_tokens,
|
||||||
|
recommended_context_tokens: RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS,
|
||||||
|
exceeds_recommended_budget,
|
||||||
|
};
|
||||||
|
let mut quality = analyze_agent_tool_catalog_quality(tools);
|
||||||
|
if exceeds_recommended_budget {
|
||||||
|
quality.findings.push(ToolQualityFinding {
|
||||||
|
severity: ToolQualitySeverity::Warning,
|
||||||
|
code: "agent_catalog_context_budget_high".to_owned(),
|
||||||
|
message: "Каталог инструментов занимает слишком много контекста модели.".to_owned(),
|
||||||
|
suggested_action: Some(
|
||||||
|
"Сократите описания и схемы либо разделите инструменты между специализированными агентами."
|
||||||
|
.to_owned(),
|
||||||
|
),
|
||||||
|
field_path: Some("agent.operations".to_owned()),
|
||||||
|
});
|
||||||
|
quality = ToolQualityReport::new(quality.findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ToolCatalogAnalysis { budget, quality })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_context_tokens(serialized_bytes: usize) -> usize {
|
||||||
|
serialized_bytes.div_ceil(ESTIMATED_TOKEN_UTF8_BYTES)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
mod unit {
|
mod unit {
|
||||||
|
mod tool_catalog;
|
||||||
mod tool_quality;
|
mod tool_quality;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use crank_core::{
|
||||||
|
ToolCatalogAnalysis, ToolQualityCatalogTool, ToolQualitySeverity, analyze_tool_catalog,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measures_the_actual_serialized_catalog_definition() {
|
||||||
|
let definitions = vec![json!({
|
||||||
|
"name": "get_exchange_rate",
|
||||||
|
"title": "Получить курс",
|
||||||
|
"description": "Возвращает актуальный курс выбранной валютной пары.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"base": {"type": "string"},
|
||||||
|
"quote": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["base", "quote"]
|
||||||
|
}
|
||||||
|
})];
|
||||||
|
|
||||||
|
let analysis = analyze_tool_catalog(&[tool("get_exchange_rate")], &definitions).unwrap();
|
||||||
|
let expected_bytes = serde_json::to_vec(&definitions[0]).unwrap().len();
|
||||||
|
|
||||||
|
assert_eq!(analysis.budget.tool_count, 1);
|
||||||
|
assert_eq!(analysis.budget.serialized_bytes, expected_bytes);
|
||||||
|
assert!(analysis.budget.estimated_context_tokens > 0);
|
||||||
|
assert_eq!(
|
||||||
|
analysis.budget.largest_tool_estimated_context_tokens,
|
||||||
|
analysis.budget.estimated_context_tokens
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warns_when_actual_catalog_exceeds_context_budget() {
|
||||||
|
let tools = (0..9)
|
||||||
|
.map(|index| tool(&format!("large_tool_{index}")))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let definitions = (0..9)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"name": format!("large_tool_{index}"),
|
||||||
|
"description": "x".repeat(1_500),
|
||||||
|
"inputSchema": {"type": "object", "properties": {}}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let analysis = analyze_tool_catalog(&tools, &definitions).unwrap();
|
||||||
|
|
||||||
|
assert!(analysis.budget.exceeds_recommended_budget);
|
||||||
|
assert!(has_warning(&analysis, "agent_catalog_context_budget_high"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_mismatch_between_catalog_tools_and_definitions() {
|
||||||
|
let error = analyze_tool_catalog(&[tool("one")], &[]).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"tool catalog and definitions length mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(name: &str) -> ToolQualityCatalogTool {
|
||||||
|
ToolQualityCatalogTool {
|
||||||
|
name: name.to_owned(),
|
||||||
|
display_name: name.to_owned(),
|
||||||
|
description: format!("Инструмент {name} выполняет одну конкретную операцию."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_warning(analysis: &ToolCatalogAnalysis, code: &str) -> bool {
|
||||||
|
analysis
|
||||||
|
.quality
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.code == code && finding.severity == ToolQualitySeverity::Warning)
|
||||||
|
}
|
||||||
@@ -193,10 +193,10 @@ fn text(value: &Value, key: &str) -> Option<String> {
|
|||||||
|
|
||||||
fn collapse_composition(value: &Value) -> Value {
|
fn collapse_composition(value: &Value) -> Value {
|
||||||
for key in ["allOf", "oneOf", "anyOf"] {
|
for key in ["allOf", "oneOf", "anyOf"] {
|
||||||
if let Some(items) = value.get(key).and_then(Value::as_array) {
|
if let Some(items) = value.get(key).and_then(Value::as_array)
|
||||||
if let Some(first) = items.first() {
|
&& let Some(first) = items.first()
|
||||||
return first.clone();
|
{
|
||||||
}
|
return first.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value.clone()
|
value.clone()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ crank-mapping = { path = "../crank-mapping" }
|
|||||||
crank-schema = { path = "../crank-schema" }
|
crank-schema = { path = "../crank-schema" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ pub mod requests {
|
|||||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||||
FinishImportJobRequest, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||||
|
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
||||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||||
UpdateWorkspaceRequest, UsageQuery,
|
UpdateWorkspaceRequest, UsageQuery,
|
||||||
@@ -45,16 +46,17 @@ pub use model::{
|
|||||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||||
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
||||||
FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
|
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportJob, ImportJobId,
|
||||||
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
|
ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord,
|
||||||
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
|
ListApprovalRequestsQuery, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||||
OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
|
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
||||||
PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
||||||
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
|
||||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
|
||||||
|
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||||
|
|||||||
@@ -568,8 +568,6 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
operation_version integer not null,
|
operation_version integer not null,
|
||||||
status text not null,
|
status text not null,
|
||||||
risk_level text not null,
|
risk_level text not null,
|
||||||
confirmation_title text not null,
|
|
||||||
confirmation_body text not null,
|
|
||||||
request_payload_json jsonb not null,
|
request_payload_json jsonb not null,
|
||||||
response_payload_json jsonb null,
|
response_payload_json jsonb null,
|
||||||
created_at timestamptz not null,
|
created_at timestamptz not null,
|
||||||
@@ -581,6 +579,29 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
query("alter table approval_requests drop column if exists confirmation_title")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
query("alter table approval_requests drop column if exists confirmation_body")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
query("alter table approval_requests add column if not exists execution_started_at timestamptz null")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
query("alter table approval_requests add column if not exists execution_attempts integer not null default 0")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
query("alter table approval_requests add column if not exists request_fingerprint text null")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
query(
|
||||||
|
"create unique index if not exists approval_requests_pending_fingerprint_idx
|
||||||
|
on approval_requests(agent_id, operation_id, operation_version, request_fingerprint)
|
||||||
|
where status = 'pending' and request_fingerprint is not null",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
query(
|
query(
|
||||||
"create index if not exists approval_requests_agent_status_idx
|
"create index if not exists approval_requests_agent_status_idx
|
||||||
on approval_requests(workspace_id, agent_id, status, expires_at)",
|
on approval_requests(workspace_id, agent_id, status, expires_at)",
|
||||||
|
|||||||
@@ -524,6 +524,13 @@ pub struct CreateApprovalRequest<'a> {
|
|||||||
pub approval: &'a ApprovalRequest,
|
pub approval: &'a ApprovalRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ListApprovalRequestsQuery<'a> {
|
||||||
|
pub workspace_id: &'a WorkspaceId,
|
||||||
|
pub status: Option<ApprovalRequestStatus>,
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DecideApprovalRequest<'a> {
|
pub struct DecideApprovalRequest<'a> {
|
||||||
pub workspace_id: &'a WorkspaceId,
|
pub workspace_id: &'a WorkspaceId,
|
||||||
@@ -536,6 +543,24 @@ pub struct DecideApprovalRequest<'a> {
|
|||||||
pub decision_note: Option<&'a str>,
|
pub decision_note: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FinishApprovalRequest<'a> {
|
||||||
|
pub workspace_id: &'a WorkspaceId,
|
||||||
|
pub agent_id: &'a AgentId,
|
||||||
|
pub approval_id: &'a ApprovalRequestId,
|
||||||
|
pub status: ApprovalRequestStatus,
|
||||||
|
pub response_payload: Option<Value>,
|
||||||
|
pub decision_note: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ExpireApprovalRequest<'a> {
|
||||||
|
pub workspace_id: &'a WorkspaceId,
|
||||||
|
pub agent_id: &'a AgentId,
|
||||||
|
pub approval_id: &'a ApprovalRequestId,
|
||||||
|
pub expired_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct CreateSecretRequest<'a> {
|
pub struct CreateSecretRequest<'a> {
|
||||||
pub secret: &'a Secret,
|
pub secret: &'a Secret,
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
impl PostgresRegistry {
|
impl PostgresRegistry {
|
||||||
pub async fn create_approval_request(
|
pub async fn create_approval_request(
|
||||||
&self,
|
&self,
|
||||||
request: CreateApprovalRequest<'_>,
|
request: CreateApprovalRequest<'_>,
|
||||||
) -> Result<(), RegistryError> {
|
) -> Result<ApprovalRequestRecord, RegistryError> {
|
||||||
|
let fingerprint = approval_request_fingerprint(&request.approval.request_payload)?;
|
||||||
|
let mut transaction = self.pool.begin().await?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
"update approval_requests
|
||||||
|
set status = 'expired'
|
||||||
|
where agent_id = $1
|
||||||
|
and operation_id = $2
|
||||||
|
and operation_version = $3
|
||||||
|
and request_fingerprint = $4
|
||||||
|
and status = 'pending'
|
||||||
|
and expires_at <= $5",
|
||||||
|
)
|
||||||
|
.bind(request.approval.agent_id.as_str())
|
||||||
|
.bind(request.approval.operation_id.as_str())
|
||||||
|
.bind(to_db_version(request.approval.operation_version))
|
||||||
|
.bind(&fingerprint)
|
||||||
|
.bind(request.approval.created_at)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
let row = sqlx::query(
|
||||||
"insert into approval_requests (
|
"insert into approval_requests (
|
||||||
id,
|
id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
@@ -14,20 +34,26 @@ impl PostgresRegistry {
|
|||||||
operation_version,
|
operation_version,
|
||||||
status,
|
status,
|
||||||
risk_level,
|
risk_level,
|
||||||
confirmation_title,
|
|
||||||
confirmation_body,
|
|
||||||
request_payload_json,
|
request_payload_json,
|
||||||
response_payload_json,
|
response_payload_json,
|
||||||
created_at,
|
created_at,
|
||||||
expires_at,
|
expires_at,
|
||||||
decided_at,
|
decided_at,
|
||||||
decided_by_key_id,
|
decided_by_key_id,
|
||||||
decision_note
|
decision_note,
|
||||||
|
request_fingerprint
|
||||||
) values (
|
) values (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||||
$11, $12::timestamptz, $13::timestamptz, $14::timestamptz,
|
$9, $10::timestamptz, $11::timestamptz, $12::timestamptz,
|
||||||
$15, $16
|
$13, $14, $15
|
||||||
)",
|
)
|
||||||
|
on conflict (agent_id, operation_id, operation_version, request_fingerprint)
|
||||||
|
where status = 'pending' and request_fingerprint is not null
|
||||||
|
do update set request_fingerprint = excluded.request_fingerprint
|
||||||
|
returning
|
||||||
|
id, workspace_id, agent_id, operation_id, operation_version,
|
||||||
|
status, risk_level, request_payload_json, response_payload_json,
|
||||||
|
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||||
)
|
)
|
||||||
.bind(request.approval.id.as_str())
|
.bind(request.approval.id.as_str())
|
||||||
.bind(request.approval.workspace_id.as_str())
|
.bind(request.approval.workspace_id.as_str())
|
||||||
@@ -42,8 +68,6 @@ impl PostgresRegistry {
|
|||||||
&request.approval.risk_level,
|
&request.approval.risk_level,
|
||||||
"approval_risk_level",
|
"approval_risk_level",
|
||||||
)?)
|
)?)
|
||||||
.bind(&request.approval.confirmation_title)
|
|
||||||
.bind(&request.approval.confirmation_body)
|
|
||||||
.bind(Json(&request.approval.request_payload))
|
.bind(Json(&request.approval.request_payload))
|
||||||
.bind(request.approval.response_payload.as_ref().map(Json))
|
.bind(request.approval.response_payload.as_ref().map(Json))
|
||||||
.bind(request.approval.created_at)
|
.bind(request.approval.created_at)
|
||||||
@@ -57,10 +81,12 @@ impl PostgresRegistry {
|
|||||||
.map(PlatformApiKeyId::as_str),
|
.map(PlatformApiKeyId::as_str),
|
||||||
)
|
)
|
||||||
.bind(request.approval.decision_note.as_deref())
|
.bind(request.approval.decision_note.as_deref())
|
||||||
.execute(&self.pool)
|
.bind(fingerprint)
|
||||||
|
.fetch_one(&mut *transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
|
||||||
Ok(())
|
map_approval_request_row(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_pending_approval_requests_for_agent(
|
pub async fn list_pending_approval_requests_for_agent(
|
||||||
@@ -77,8 +103,6 @@ impl PostgresRegistry {
|
|||||||
operation_version,
|
operation_version,
|
||||||
status,
|
status,
|
||||||
risk_level,
|
risk_level,
|
||||||
confirmation_title,
|
|
||||||
confirmation_body,
|
|
||||||
request_payload_json,
|
request_payload_json,
|
||||||
response_payload_json,
|
response_payload_json,
|
||||||
created_at,
|
created_at,
|
||||||
@@ -116,8 +140,6 @@ impl PostgresRegistry {
|
|||||||
operation_version,
|
operation_version,
|
||||||
status,
|
status,
|
||||||
risk_level,
|
risk_level,
|
||||||
confirmation_title,
|
|
||||||
confirmation_body,
|
|
||||||
request_payload_json,
|
request_payload_json,
|
||||||
response_payload_json,
|
response_payload_json,
|
||||||
created_at,
|
created_at,
|
||||||
@@ -140,6 +162,79 @@ impl PostgresRegistry {
|
|||||||
row.map(map_approval_request_row).transpose()
|
row.map(map_approval_request_row).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_approval_requests(
|
||||||
|
&self,
|
||||||
|
query: ListApprovalRequestsQuery<'_>,
|
||||||
|
) -> Result<Vec<ApprovalRequestRecord>, RegistryError> {
|
||||||
|
let status = query
|
||||||
|
.status
|
||||||
|
.map(|status| serialize_enum_text(&status, "approval_status"))
|
||||||
|
.transpose()?;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"select
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
|
operation_id,
|
||||||
|
operation_version,
|
||||||
|
status,
|
||||||
|
risk_level,
|
||||||
|
request_payload_json,
|
||||||
|
response_payload_json,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
decided_at,
|
||||||
|
decided_by_key_id,
|
||||||
|
decision_note
|
||||||
|
from approval_requests
|
||||||
|
where workspace_id = $1
|
||||||
|
and ($2::text is null or status = $2)
|
||||||
|
order by created_at desc
|
||||||
|
limit $3",
|
||||||
|
)
|
||||||
|
.bind(query.workspace_id.as_str())
|
||||||
|
.bind(status.as_deref())
|
||||||
|
.bind(i64::from(query.limit))
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
rows.into_iter().map(map_approval_request_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_approval_request(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
approval_id: &ApprovalRequestId,
|
||||||
|
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"select
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
|
operation_id,
|
||||||
|
operation_version,
|
||||||
|
status,
|
||||||
|
risk_level,
|
||||||
|
request_payload_json,
|
||||||
|
response_payload_json,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
decided_at,
|
||||||
|
decided_by_key_id,
|
||||||
|
decision_note
|
||||||
|
from approval_requests
|
||||||
|
where workspace_id = $1
|
||||||
|
and id = $2
|
||||||
|
limit 1",
|
||||||
|
)
|
||||||
|
.bind(workspace_id.as_str())
|
||||||
|
.bind(approval_id.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.map(map_approval_request_row).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn decide_approval_request(
|
pub async fn decide_approval_request(
|
||||||
&self,
|
&self,
|
||||||
request: DecideApprovalRequest<'_>,
|
request: DecideApprovalRequest<'_>,
|
||||||
@@ -164,8 +259,6 @@ impl PostgresRegistry {
|
|||||||
operation_version,
|
operation_version,
|
||||||
status,
|
status,
|
||||||
risk_level,
|
risk_level,
|
||||||
confirmation_title,
|
|
||||||
confirmation_body,
|
|
||||||
request_payload_json,
|
request_payload_json,
|
||||||
response_payload_json,
|
response_payload_json,
|
||||||
created_at,
|
created_at,
|
||||||
@@ -187,6 +280,174 @@ impl PostgresRegistry {
|
|||||||
|
|
||||||
row.map(map_approval_request_row).transpose()
|
row.map(map_approval_request_row).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn finish_approval_request(
|
||||||
|
&self,
|
||||||
|
request: FinishApprovalRequest<'_>,
|
||||||
|
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"update approval_requests
|
||||||
|
set status = $1,
|
||||||
|
response_payload_json = $2,
|
||||||
|
decision_note = coalesce($3, decision_note)
|
||||||
|
where workspace_id = $4
|
||||||
|
and agent_id = $5
|
||||||
|
and id = $6
|
||||||
|
and status = 'executing'
|
||||||
|
returning
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
|
operation_id,
|
||||||
|
operation_version,
|
||||||
|
status,
|
||||||
|
risk_level,
|
||||||
|
request_payload_json,
|
||||||
|
response_payload_json,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
decided_at,
|
||||||
|
decided_by_key_id,
|
||||||
|
decision_note",
|
||||||
|
)
|
||||||
|
.bind(serialize_enum_text(&request.status, "approval_status")?)
|
||||||
|
.bind(request.response_payload.as_ref().map(Json))
|
||||||
|
.bind(request.decision_note)
|
||||||
|
.bind(request.workspace_id.as_str())
|
||||||
|
.bind(request.agent_id.as_str())
|
||||||
|
.bind(request.approval_id.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.map(map_approval_request_row).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn claim_approval_request(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
approval_id: &ApprovalRequestId,
|
||||||
|
started_at: OffsetDateTime,
|
||||||
|
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"update approval_requests
|
||||||
|
set status = 'executing',
|
||||||
|
execution_started_at = $1,
|
||||||
|
execution_attempts = execution_attempts + 1
|
||||||
|
where workspace_id = $2
|
||||||
|
and agent_id = $3
|
||||||
|
and id = $4
|
||||||
|
and status = 'approved'
|
||||||
|
returning
|
||||||
|
id, workspace_id, agent_id, operation_id, operation_version,
|
||||||
|
status, risk_level, request_payload_json, response_payload_json,
|
||||||
|
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||||
|
)
|
||||||
|
.bind(started_at)
|
||||||
|
.bind(workspace_id.as_str())
|
||||||
|
.bind(agent_id.as_str())
|
||||||
|
.bind(approval_id.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.map(map_approval_request_row).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn claim_next_recoverable_approval_request(
|
||||||
|
&self,
|
||||||
|
started_at: OffsetDateTime,
|
||||||
|
approved_before: OffsetDateTime,
|
||||||
|
stale_before: OffsetDateTime,
|
||||||
|
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"with candidate as (
|
||||||
|
select id
|
||||||
|
from approval_requests
|
||||||
|
where (status = 'approved' and decided_at <= $1)
|
||||||
|
or (status = 'executing' and execution_started_at < $2)
|
||||||
|
order by decided_at asc nulls last, created_at asc
|
||||||
|
for update skip locked
|
||||||
|
limit 1
|
||||||
|
)
|
||||||
|
update approval_requests as approval
|
||||||
|
set status = 'executing',
|
||||||
|
execution_started_at = $3,
|
||||||
|
execution_attempts = approval.execution_attempts + 1
|
||||||
|
from candidate
|
||||||
|
where approval.id = candidate.id
|
||||||
|
returning
|
||||||
|
approval.id, approval.workspace_id, approval.agent_id,
|
||||||
|
approval.operation_id, approval.operation_version, approval.status,
|
||||||
|
approval.risk_level, approval.request_payload_json,
|
||||||
|
approval.response_payload_json, approval.created_at, approval.expires_at,
|
||||||
|
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||||
|
)
|
||||||
|
.bind(approved_before)
|
||||||
|
.bind(stale_before)
|
||||||
|
.bind(started_at)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.map(map_approval_request_row).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn expire_approval_request(
|
||||||
|
&self,
|
||||||
|
request: ExpireApprovalRequest<'_>,
|
||||||
|
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"update approval_requests
|
||||||
|
set status = 'expired'
|
||||||
|
where workspace_id = $1
|
||||||
|
and agent_id = $2
|
||||||
|
and id = $3
|
||||||
|
and status = 'pending'
|
||||||
|
and expires_at <= $4::timestamptz
|
||||||
|
returning
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
|
operation_id,
|
||||||
|
operation_version,
|
||||||
|
status,
|
||||||
|
risk_level,
|
||||||
|
request_payload_json,
|
||||||
|
response_payload_json,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
decided_at,
|
||||||
|
decided_by_key_id,
|
||||||
|
decision_note",
|
||||||
|
)
|
||||||
|
.bind(request.workspace_id.as_str())
|
||||||
|
.bind(request.agent_id.as_str())
|
||||||
|
.bind(request.approval_id.as_str())
|
||||||
|
.bind(request.expired_at)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.map(map_approval_request_row).transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn approval_request_fingerprint(payload: &Value) -> Result<String, RegistryError> {
|
||||||
|
let canonical = canonical_json(payload);
|
||||||
|
let encoded = serde_json::to_vec(&canonical)?;
|
||||||
|
Ok(format!("{:x}", Sha256::digest(encoded)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_json(value: &Value) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
let sorted = object
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), canonical_json(value)))
|
||||||
|
.collect::<std::collections::BTreeMap<_, _>>();
|
||||||
|
Value::Object(sorted.into_iter().collect())
|
||||||
|
}
|
||||||
|
Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()),
|
||||||
|
_ => value.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, RegistryError> {
|
fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, RegistryError> {
|
||||||
@@ -202,8 +463,6 @@ fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, Registr
|
|||||||
&row.get::<String, _>("risk_level"),
|
&row.get::<String, _>("risk_level"),
|
||||||
"approval_risk_level",
|
"approval_risk_level",
|
||||||
)?,
|
)?,
|
||||||
confirmation_title: row.get("confirmation_title"),
|
|
||||||
confirmation_body: row.get("confirmation_body"),
|
|
||||||
request_payload: row.get::<Value, _>("request_payload_json"),
|
request_payload: row.get::<Value, _>("request_payload_json"),
|
||||||
response_payload: row.get::<Option<Value>, _>("response_payload_json"),
|
response_payload: row.get::<Option<Value>, _>("response_payload_json"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
PgPool,
|
AssertSqlSafe, PgPool,
|
||||||
postgres::{PgConnectOptions, PgPoolOptions},
|
postgres::{PgConnectOptions, PgPoolOptions},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ impl PostgresRegistry {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(schema) = schema {
|
if let Some(schema) = schema {
|
||||||
sqlx::query(&format!("set search_path to {schema}"))
|
sqlx::query(AssertSqlSafe(format!("set search_path to {schema}")))
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ use crate::{
|
|||||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||||
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord,
|
DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||||
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||||
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation,
|
||||||
|
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||||
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest,
|
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest,
|
||||||
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ impl PostgresRegistry {
|
|||||||
&self,
|
&self,
|
||||||
request: CreateInvocationLogRequest<'_>,
|
request: CreateInvocationLogRequest<'_>,
|
||||||
) -> Result<(), RegistryError> {
|
) -> Result<(), RegistryError> {
|
||||||
|
let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview);
|
||||||
|
let response_preview =
|
||||||
|
crank_core::sanitize_invocation_preview(&request.log.response_preview);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"insert into invocation_logs (
|
"insert into invocation_logs (
|
||||||
id,
|
id,
|
||||||
@@ -45,8 +48,8 @@ impl PostgresRegistry {
|
|||||||
}
|
}
|
||||||
})?)
|
})?)
|
||||||
.bind(&request.log.error_kind)
|
.bind(&request.log.error_kind)
|
||||||
.bind(Json(request.log.request_preview.clone()))
|
.bind(Json(request_preview))
|
||||||
.bind(Json(request.log.response_preview.clone()))
|
.bind(Json(response_preview))
|
||||||
.bind(request.log.created_at)
|
.bind(request.log.created_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -239,7 +242,7 @@ impl PostgresRegistry {
|
|||||||
group by 1
|
group by 1
|
||||||
order by 1 asc"
|
order by 1 asc"
|
||||||
);
|
);
|
||||||
let rows = sqlx::query(&sql)
|
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||||
.bind(query.workspace_id.as_str())
|
.bind(query.workspace_id.as_str())
|
||||||
.bind(query.created_after)
|
.bind(query.created_after)
|
||||||
.bind(
|
.bind(
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ impl TestDatabase {
|
|||||||
let schema = crank_test_support::unique_schema_name("test_registry");
|
let schema = crank_test_support::unique_schema_name("test_registry");
|
||||||
|
|
||||||
admin_pool
|
admin_pool
|
||||||
.execute(sqlx::query(&format!("create schema {schema}")))
|
.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||||
|
"create schema {schema}"
|
||||||
|
))))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -267,10 +269,10 @@ impl TestDatabase {
|
|||||||
|
|
||||||
pub(super) async fn cleanup(&self) {
|
pub(super) async fn cleanup(&self) {
|
||||||
self.admin_pool
|
self.admin_pool
|
||||||
.execute(sqlx::query(&format!(
|
.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||||
"drop schema if exists {} cascade",
|
"drop schema if exists {} cascade",
|
||||||
self.schema
|
self.schema
|
||||||
)))
|
))))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ use crank_registry::{
|
|||||||
CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest,
|
CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest,
|
||||||
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||||
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
||||||
OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest,
|
FinishApprovalRequest, ListApprovalRequestsQuery, OperationSampleMetadata,
|
||||||
PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest,
|
PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError,
|
||||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest,
|
RegistryOperation, SampleKind, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||||
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion,
|
||||||
|
YamlImportJobId, YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn test_workspace_id() -> WorkspaceId {
|
fn test_workspace_id() -> WorkspaceId {
|
||||||
@@ -486,8 +487,6 @@ async fn manages_approval_request_lifecycle() {
|
|||||||
operation_version: 1,
|
operation_version: 1,
|
||||||
status: ApprovalRequestStatus::Pending,
|
status: ApprovalRequestStatus::Pending,
|
||||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||||
confirmation_title: "Confirm action".to_owned(),
|
|
||||||
confirmation_body: "Check payload before running.".to_owned(),
|
|
||||||
request_payload: json!({"amount": 100}),
|
request_payload: json!({"amount": 100}),
|
||||||
response_payload: None,
|
response_payload: None,
|
||||||
created_at: timestamp("2026-03-25T12:01:00Z"),
|
created_at: timestamp("2026-03-25T12:01:00Z"),
|
||||||
@@ -516,12 +515,23 @@ async fn manages_approval_request_lifecycle() {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
registry
|
let created = registry
|
||||||
.create_approval_request(CreateApprovalRequest {
|
.create_approval_request(CreateApprovalRequest {
|
||||||
approval: &approval,
|
approval: &approval,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(created.approval.id, approval.id);
|
||||||
|
let mut duplicate = approval.clone();
|
||||||
|
duplicate.id = ApprovalRequestId::new("approval_02");
|
||||||
|
duplicate.request_payload = json!({"amount": 100});
|
||||||
|
let deduplicated = registry
|
||||||
|
.create_approval_request(CreateApprovalRequest {
|
||||||
|
approval: &duplicate,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(deduplicated.approval.id, approval.id);
|
||||||
|
|
||||||
let pending = registry
|
let pending = registry
|
||||||
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
|
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
|
||||||
@@ -530,6 +540,24 @@ async fn manages_approval_request_lifecycle() {
|
|||||||
assert_eq!(pending.len(), 1);
|
assert_eq!(pending.len(), 1);
|
||||||
assert_eq!(pending[0].approval, approval);
|
assert_eq!(pending[0].approval, approval);
|
||||||
|
|
||||||
|
let workspace_approvals = registry
|
||||||
|
.list_approval_requests(ListApprovalRequestsQuery {
|
||||||
|
workspace_id: &workspace_id,
|
||||||
|
status: None,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(workspace_approvals.len(), 1);
|
||||||
|
assert_eq!(workspace_approvals[0].approval.id, approval.id);
|
||||||
|
|
||||||
|
let fetched = registry
|
||||||
|
.get_approval_request(&workspace_id, &approval.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fetched.approval, approval);
|
||||||
|
|
||||||
let decided = registry
|
let decided = registry
|
||||||
.decide_approval_request(DecideApprovalRequest {
|
.decide_approval_request(DecideApprovalRequest {
|
||||||
workspace_id: &workspace_id,
|
workspace_id: &workspace_id,
|
||||||
@@ -555,6 +583,81 @@ async fn manages_approval_request_lifecycle() {
|
|||||||
Some(json!({"approve": "yes"}))
|
Some(json!({"approve": "yes"}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let approval_inside_recovery_grace = registry
|
||||||
|
.claim_next_recoverable_approval_request(
|
||||||
|
timestamp("2026-03-25T12:02:01Z"),
|
||||||
|
timestamp("2026-03-25T12:01:59Z"),
|
||||||
|
timestamp("2026-03-25T11:55:00Z"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(approval_inside_recovery_grace.is_none());
|
||||||
|
|
||||||
|
let claimed = registry
|
||||||
|
.claim_approval_request(
|
||||||
|
&workspace_id,
|
||||||
|
&agent.id,
|
||||||
|
&approval.id,
|
||||||
|
timestamp("2026-03-25T12:02:01Z"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(claimed.approval.status, ApprovalRequestStatus::Executing);
|
||||||
|
let fresh_claim = registry
|
||||||
|
.claim_next_recoverable_approval_request(
|
||||||
|
timestamp("2026-03-25T12:02:10Z"),
|
||||||
|
timestamp("2026-03-25T12:02:09Z"),
|
||||||
|
timestamp("2026-03-25T12:01:59Z"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(fresh_claim.is_none());
|
||||||
|
let recovered = registry
|
||||||
|
.claim_next_recoverable_approval_request(
|
||||||
|
timestamp("2026-03-25T12:03:00Z"),
|
||||||
|
timestamp("2026-03-25T12:02:59Z"),
|
||||||
|
timestamp("2026-03-25T12:02:30Z"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(recovered.approval.id, approval.id);
|
||||||
|
assert_eq!(recovered.approval.status, ApprovalRequestStatus::Executing);
|
||||||
|
|
||||||
|
let completed = registry
|
||||||
|
.finish_approval_request(FinishApprovalRequest {
|
||||||
|
workspace_id: &workspace_id,
|
||||||
|
agent_id: &agent.id,
|
||||||
|
approval_id: &approval.id,
|
||||||
|
status: ApprovalRequestStatus::Completed,
|
||||||
|
response_payload: Some(json!({"id": "lead_123"})),
|
||||||
|
decision_note: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(completed.approval.status, ApprovalRequestStatus::Completed);
|
||||||
|
assert_eq!(
|
||||||
|
completed.approval.response_payload,
|
||||||
|
Some(json!({"id": "lead_123"}))
|
||||||
|
);
|
||||||
|
|
||||||
|
let completed_by_status = registry
|
||||||
|
.list_approval_requests(ListApprovalRequestsQuery {
|
||||||
|
workspace_id: &workspace_id,
|
||||||
|
status: Some(ApprovalRequestStatus::Completed),
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(completed_by_status.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
completed_by_status[0].approval.status,
|
||||||
|
ApprovalRequestStatus::Completed
|
||||||
|
);
|
||||||
|
|
||||||
let pending_after_decision = registry
|
let pending_after_decision = registry
|
||||||
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
|
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
|
||||||
.await
|
.await
|
||||||
@@ -576,5 +679,26 @@ async fn manages_approval_request_lifecycle() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(repeated_decision.is_none());
|
assert!(repeated_decision.is_none());
|
||||||
|
|
||||||
|
let mut expired = approval.clone();
|
||||||
|
expired.id = ApprovalRequestId::new("approval_expired_pending");
|
||||||
|
expired.request_payload = json!({"amount": 200});
|
||||||
|
expired.created_at = timestamp("2026-03-25T12:04:00Z");
|
||||||
|
expired.expires_at = timestamp("2026-03-25T12:04:01Z");
|
||||||
|
registry
|
||||||
|
.create_approval_request(CreateApprovalRequest { approval: &expired })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut replacement = expired.clone();
|
||||||
|
replacement.id = ApprovalRequestId::new("approval_replacement_pending");
|
||||||
|
replacement.created_at = timestamp("2026-03-25T12:05:00Z");
|
||||||
|
replacement.expires_at = timestamp("2026-03-25T12:10:00Z");
|
||||||
|
let replaced = registry
|
||||||
|
.create_approval_request(CreateApprovalRequest {
|
||||||
|
approval: &replacement,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(replaced.approval.id, replacement.id);
|
||||||
|
|
||||||
database.cleanup().await;
|
database.cleanup().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ pub async fn confirm_operation(
|
|||||||
if !safety.class.requires_confirmation() {
|
if !safety.class.requires_confirmation() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if request_context.is_some_and(RuntimeRequestContext::approval_granted) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let Some(store) = store else {
|
let Some(store) = store else {
|
||||||
return Err(RuntimeError::ConfirmationStoreUnavailable {
|
return Err(RuntimeError::ConfirmationStoreUnavailable {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crank_adapter_rest::RestAdapter;
|
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
AdapterRegistry, CoordinationStateStore, MeteringSink, NoopMeteringSink, ResponseCacheStore,
|
AdapterRegistry, CoordinationStateStore, MeteringSink, NoopMeteringSink, ResponseCacheStore,
|
||||||
SharedMeteringSink, SharedProtocolAdapter,
|
SharedMeteringSink, SharedProtocolAdapter,
|
||||||
@@ -73,3 +73,13 @@ pub fn community_default() -> RuntimeExecutorBuilder {
|
|||||||
RuntimeExecutorBuilder::new()
|
RuntimeExecutorBuilder::new()
|
||||||
.register_adapter(Arc::new(RestAdapter::new()) as SharedProtocolAdapter)
|
.register_adapter(Arc::new(RestAdapter::new()) as SharedProtocolAdapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn community_from_env() -> Result<RuntimeExecutorBuilder, RestAdapterError> {
|
||||||
|
Ok(RuntimeExecutorBuilder::new()
|
||||||
|
.register_adapter(Arc::new(RestAdapter::from_env()?) as SharedProtocolAdapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn community_with_outbound_policy(policy: OutboundHttpPolicy) -> RuntimeExecutorBuilder {
|
||||||
|
RuntimeExecutorBuilder::new()
|
||||||
|
.register_adapter(Arc::new(RestAdapter::with_policy(policy)) as SharedProtocolAdapter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,10 +85,9 @@ fn key_from_policy(
|
|||||||
.header_name
|
.header_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
|
&& let Some(value) = prepared_request.headers.get(header_name)
|
||||||
{
|
{
|
||||||
if let Some(value) = prepared_request.headers.get(header_name) {
|
return Some(value.clone());
|
||||||
return Some(value.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let field_name = policy.input_field.as_deref()?.trim();
|
let field_name = policy.input_field.as_deref()?.trim();
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ pub use cache::{
|
|||||||
pub use cache_factory::{
|
pub use cache_factory::{
|
||||||
BuiltinCacheBackendFactory, CacheBackendFactory, SharedCacheBackendFactory,
|
BuiltinCacheBackendFactory, CacheBackendFactory, SharedCacheBackendFactory,
|
||||||
};
|
};
|
||||||
|
pub use crank_adapter_rest::OutboundHttpPolicy;
|
||||||
pub use error::RuntimeError;
|
pub use error::RuntimeError;
|
||||||
pub use executor::{RuntimeExecutionRequest, RuntimeExecutor};
|
pub use executor::{RuntimeExecutionRequest, RuntimeExecutor};
|
||||||
pub use executor_builder::{RuntimeExecutorBuilder, community_default};
|
pub use executor_builder::{
|
||||||
|
RuntimeExecutorBuilder, community_default, community_from_env, community_with_outbound_policy,
|
||||||
|
};
|
||||||
pub use limits::{RuntimeLimits, RuntimeLimitsConfigError};
|
pub use limits::{RuntimeLimits, RuntimeLimitsConfigError};
|
||||||
pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation};
|
pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation};
|
||||||
pub use rate_limit::{
|
pub use rate_limit::{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub struct RuntimeRequestContext {
|
|||||||
pub response_cache_scope: Option<ResponseCacheScope>,
|
pub response_cache_scope: Option<ResponseCacheScope>,
|
||||||
pub metering_context: Option<MeteringContext>,
|
pub metering_context: Option<MeteringContext>,
|
||||||
pub confirmation_token: Option<String>,
|
pub confirmation_token: Option<String>,
|
||||||
|
pub approval_granted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -32,6 +33,7 @@ impl RuntimeRequestContext {
|
|||||||
response_cache_scope: None,
|
response_cache_scope: None,
|
||||||
metering_context: None,
|
metering_context: None,
|
||||||
confirmation_token: None,
|
confirmation_token: None,
|
||||||
|
approval_granted: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,15 @@ impl RuntimeRequestContext {
|
|||||||
pub fn confirmation_token(&self) -> Option<&str> {
|
pub fn confirmation_token(&self) -> Option<&str> {
|
||||||
self.confirmation_token.as_deref()
|
self.confirmation_token.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_approval_granted(mut self) -> Self {
|
||||||
|
self.approval_granted = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn approval_granted(&self) -> bool {
|
||||||
|
self.approval_granted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ResponseCacheScope> for crank_core::ResponseCacheScope {
|
impl From<ResponseCacheScope> for crank_core::ResponseCacheScope {
|
||||||
@@ -154,6 +165,7 @@ mod tests {
|
|||||||
assert_eq!(context.correlation_id, "req_123");
|
assert_eq!(context.correlation_id, "req_123");
|
||||||
assert!(context.response_cache_scope.is_none());
|
assert!(context.response_cache_scope.is_none());
|
||||||
assert!(context.confirmation_token.is_none());
|
assert!(context.confirmation_token.is_none());
|
||||||
|
assert!(!context.approval_granted());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -187,4 +199,11 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(context.confirmation_token(), Some("ct_123"));
|
assert_eq!(context.confirmation_token(), Some("ct_123"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_prior_human_approval() {
|
||||||
|
let context = RuntimeRequestContext::from_request_id("req_123").with_approval_granted();
|
||||||
|
|
||||||
|
assert!(context.approval_granted());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ async fn destructive_operation_requires_single_use_confirmation() {
|
|||||||
RuntimeError::InvalidConfirmationToken { .. }
|
RuntimeError::InvalidConfirmationToken { .. }
|
||||||
));
|
));
|
||||||
assert_eq!(call_count.load(Ordering::SeqCst), 1);
|
assert_eq!(call_count.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
let approved_context = context.with_approval_granted();
|
||||||
|
let approved = executor
|
||||||
|
.execute_with_context(
|
||||||
|
&operation,
|
||||||
|
&json!({ "order_id": "ord_123" }),
|
||||||
|
Some(&approved_context),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(approved, json!({ "deleted": true }));
|
||||||
|
assert_eq!(call_count.load(Ordering::SeqCst), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CountingAdapter {
|
struct CountingAdapter {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user