Compare commits

...

30 Commits

Author SHA1 Message Date
bsodfather fd8571ad10 Обновить зависимости проекта
CI / Rust Checks (pull_request) Failing after 3s
CI / UI Checks (pull_request) Has been skipped
CI / Frontend E2E (pull_request) Has been skipped
CI / Deployment Manifests (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped
2026-07-06 22:47:24 +03:00
github-ops c7e5efa976 Polish Russian approval copy
CI / Rust Checks (push) Successful in 5m39s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m22s
CI / Deploy (push) Successful in 1m30s
2026-06-27 09:00:02 +00:00
github-ops 4da13c0811 Explain approval payload preview
CI / Rust Checks (push) Successful in 5m37s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m29s
CI / Deploy (push) Successful in 1m36s
2026-06-27 08:50:10 +00:00
github-ops 4cad7f1c46 Simplify approval payload settings
CI / Rust Checks (push) Successful in 5m38s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 3m27s
CI / Deploy (push) Successful in 1m30s
2026-06-27 08:33:16 +00:00
github-ops 700a684257 Add approval mode selection
CI / Rust Checks (push) Successful in 5m35s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 3m28s
CI / Deploy (push) Successful in 1m38s
2026-06-27 07:55:38 +00:00
github-ops 2b2ff92146 Remove approval confirmation copy
CI / Rust Checks (push) Successful in 5m32s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m24s
CI / Deploy (push) Successful in 1m33s
2026-06-27 07:12:04 +00:00
github-ops d34c8a73d6 Fix API keys header layout
CI / Rust Checks (push) Successful in 5m34s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m22s
CI / Deploy (push) Successful in 1m31s
2026-06-25 06:40:53 +00:00
github-ops 9ba2aa3f38 Clarify request mapping UI
CI / Rust Checks (push) Successful in 5m37s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m28s
CI / Deploy (push) Successful in 1m31s
2026-06-25 04:59:12 +00:00
github-ops 209b3e1485 Polish approval and import UI
CI / Rust Checks (push) Successful in 5m32s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m30s
CI / Deploy (push) Successful in 1m33s
2026-06-24 21:51:13 +00:00
github-ops 3b51cb89df Fix OpenAPI import modal background
CI / Rust Checks (push) Successful in 5m33s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 3m29s
CI / Deploy (push) Successful in 1m28s
2026-06-24 21:12:46 +00:00
github-ops 861502aabc Use preinstalled Rust binaries in CI
CI / Rust Checks (push) Successful in 5m38s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 4m13s
CI / Deploy (push) Successful in 1m35s
2026-06-24 17:04:29 +00:00
github-ops 327bea6f33 Fail fast when runner Rust toolchain is missing
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled
2026-06-24 16:51:00 +00:00
github-ops 87d9ba2299 Prepare Rust toolchain explicitly in CI
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled
2026-06-24 16:26:21 +00:00
github-ops de1bcc5cae Restore Gitea runner label
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled
2026-06-24 15:44:24 +00:00
github-ops 2f6e1d5e51 Use rust stable runner for Gitea workflows
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled
2026-06-24 15:40:04 +00:00
github-ops 0d828257c0 Split MCP approval integration tests
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled
2026-06-24 15:36:28 +00:00
github-ops 8b8f2fc6c5 Expose approval requests in admin logs
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled
2026-06-24 13:51:54 +00:00
github-ops 7aad3b1228 Expire stale approval requests
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled
2026-06-24 13:27:00 +00:00
github-ops 8ce00ede31 Make approval decisions idempotent
CI / Rust Checks (push) Has been cancelled
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
2026-06-24 13:23:04 +00:00
github-ops 267061e226 Execute approved tool calls
CI / Rust Checks (push) Successful in 1h31m49s
CI / UI Checks (push) Successful in 5s
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
2026-06-24 12:49:57 +00:00
github-ops 78d3052a61 Document human approval flow
CI / Rust Checks (push) Successful in 1h25m31s
CI / UI Checks (push) Successful in 6s
CI / Deployment Manifests (push) Successful in 2s
CI / Deploy (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
2026-06-24 11:43:34 +00:00
github-ops 0d193b84dd Gate MCP tool calls on human approval 2026-06-24 11:42:16 +00:00
github-ops 8a1cc1746f Add approval request HTTP surface 2026-06-24 11:39:39 +00:00
github-ops d83ab541d9 Add operation approval policy editor 2026-06-24 10:45:09 +00:00
github-ops 5922aea68f Split MCP and approval agent keys 2026-06-24 10:31:52 +00:00
github-ops d739f17393 Polish OpenAPI import modal
CI / Rust Checks (push) Successful in 5m49s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m23s
CI / Deploy (push) Successful in 1m29s
2026-06-24 07:18:02 +00:00
github-ops dbb75871ae Remove manual deploy workflow
CI / Rust Checks (push) Successful in 6m11s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Failing after 13m37s
CI / Deploy (push) Has been skipped
2026-06-24 06:41:10 +00:00
github-ops 50ae60952a Gate deploy on successful CI
CI / Rust Checks (push) Successful in 6m7s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Failing after 6m47s
CI / Deploy (push) Has been skipped
2026-06-24 06:33:16 +00:00
github-ops 5fb3d37329 Keep startup demo cleanup non-fatal
Deploy / deploy (push) Successful in 1m38s
CI / Rust Checks (push) Successful in 6m36s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Failing after 13m30s
2026-06-24 06:24:29 +00:00
github-ops 6d36b5e262 Fix community OpenAPI import docs scope
Deploy / deploy (push) Failing after 1m57s
CI / Rust Checks (push) Successful in 6m20s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Failing after 13m43s
2026-06-24 06:20:42 +00:00
77 changed files with 4941 additions and 1512 deletions
+269
View File
@@ -22,6 +22,23 @@ jobs:
- name: Checkout
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
run: |
python3 --version
@@ -104,6 +121,23 @@ jobs:
- name: Checkout
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
run: |
rustc --version
@@ -143,3 +177,238 @@ jobs:
- name: Validate Community deployment manifest
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
deploy:
name: Deploy
runs-on: ubuntu-latest
needs:
- rust
- ui
- frontend-e2e
- deployment
if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }}
env:
REGISTRY: git.itexp.me
IMAGE_TAG: ${{ gitea.sha }}
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
OPENBAO_ENV_FILE: .openbao-env
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Verify runner toolchain
run: |
docker --version
command -v bao
bao version
- name: Load deployment secrets from OpenBao
env:
BAO_ADDR: ${{ secrets.BAO_ADDR }}
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
OPENBAO_APP: crank
run: scripts/load-openbao-env.sh
- name: Login to registry
run: |
. "$OPENBAO_ENV_FILE"
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
- name: Build and push images
run: |
docker build -f apps/admin-api/Dockerfile \
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.ADMIN_API_IMAGE }}:main' \
.
docker build -f apps/mcp-server/Dockerfile \
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.MCP_SERVER_IMAGE }}:main' \
.
docker build -f apps/ui/Dockerfile \
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.UI_IMAGE }}:main' \
.
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:main'
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.MCP_SERVER_IMAGE }}:main'
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.UI_IMAGE }}:main'
- name: Configure SSH key
run: |
. "$OPENBAO_ENV_FILE"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Configure known hosts
run: |
. "$OPENBAO_ENV_FILE"
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
else
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts
fi
chmod 644 ~/.ssh/known_hosts
- name: Sync deployment files to server
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH'"
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
- name: Write environment file
run: |
. "$OPENBAO_ENV_FILE"
tmp_env="$(mktemp)"
append_if_set() {
if [ -n "$2" ]; then
printf '%s=%s\n' "$1" "$2" >> "$tmp_env"
fi
}
: > "$tmp_env"
append_if_set POSTGRES_DB "$POSTGRES_DB"
append_if_set POSTGRES_USER "$POSTGRES_USER"
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
if [ -n "${POSTGRES_PORT:-}" ]; then
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
elif [ -n "${PGBOUNCER_PORT:-}" ]; then
append_if_set POSTGRES_PORT "$PGBOUNCER_PORT"
fi
append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT"
append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND"
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_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_LOG_LEVEL "$CRANK_LOG_LEVEL"
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL"
append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS"
append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET"
append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER"
append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS"
append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL"
append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD"
append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME"
append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED"
{
printf 'COMPOSE_PROJECT_NAME=community\n'
printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}'
printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}'
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
} >> "$tmp_env"
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH' && cat > '$DEPLOY_PATH/.env'"
rm -f "$tmp_env"
- name: Validate required environment variables
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
set -e
cd '$DEPLOY_PATH'
required_vars='
POSTGRES_HOST
POSTGRES_PORT
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
CRANK_MASTER_KEY
CRANK_SESSION_SECRET
CRANK_PASSWORD_PEPPER
CRANK_BOOTSTRAP_ADMIN_EMAIL
CRANK_BOOTSTRAP_ADMIN_PASSWORD
CRANK_BASE_URL
'
for var in \$required_vars; do
value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true)
if [ -z \"\$value\" ]; then
echo \"missing required env: \$var\" >&2
exit 1
fi
done
"
- name: Deploy with Docker Compose
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
set -e
cd '$DEPLOY_PATH'
compose_profiles=''
cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true)
if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then
compose_profiles='--profile cache'
fi
echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin
docker compose \$compose_profiles config -q
docker compose \$compose_profiles pull
docker compose \$compose_profiles down --remove-orphans
for container in \
crank-ui-1 \
crank-admin-api-1 \
crank-mcp-server-1 \
crank-postgres-1 \
crank-valkey-1 \
crank-community-ui-1 \
crank-community-admin-api-1 \
crank-community-mcp-server-1 \
crank-community-postgres-1 \
crank-community-valkey-1; do
if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then
docker rm -f \"\$container\"
fi
done
echo 'Docker containers before freeing required ports:'
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'
for port in 3000 3001 3002; do
container_ids=\$(docker ps -aq --filter \"publish=\$port\")
if [ -n \"\$container_ids\" ]; then
echo \"Removing containers publishing port \$port\"
docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true
docker rm -f \$container_ids
fi
done
if command -v ss >/dev/null 2>&1; then
ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true
fi
docker compose \$compose_profiles up -d --remove-orphans
"
- name: Verify health endpoints
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
set -e
cd '$DEPLOY_PATH'
for attempt in \$(seq 1 30); do
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
&& curl --fail --silent http://127.0.0.1:3001/health >/dev/null \
&& curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then
exit 0
fi
sleep 2
done
echo 'deployment health verification failed' >&2
docker compose ps >&2
exit 1
"
- name: Run authenticated product smoke
run: |
. "$OPENBAO_ENV_FILE"
CRANK_STAGING_ADMIN_EMAIL="$CRANK_BOOTSTRAP_ADMIN_EMAIL" \
CRANK_STAGING_ADMIN_PASSWORD="$CRANK_BOOTSTRAP_ADMIN_PASSWORD" \
scripts/authenticated-product-smoke.sh "$CRANK_BASE_URL"
-237
View File
@@ -1,237 +0,0 @@
name: Deploy
on:
push:
branches:
- main
workflow_dispatch:
env:
REGISTRY: git.itexp.me
IMAGE_TAG: ${{ gitea.sha }}
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
OPENBAO_ENV_FILE: .openbao-env
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Verify runner toolchain
run: |
docker --version
command -v bao
bao version
- name: Load deployment secrets from OpenBao
env:
BAO_ADDR: ${{ secrets.BAO_ADDR }}
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
OPENBAO_APP: crank
run: scripts/load-openbao-env.sh
- name: Login to registry
run: |
. "$OPENBAO_ENV_FILE"
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
- name: Build and push images
run: |
docker build -f apps/admin-api/Dockerfile \
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.ADMIN_API_IMAGE }}:main' \
.
docker build -f apps/mcp-server/Dockerfile \
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.MCP_SERVER_IMAGE }}:main' \
.
docker build -f apps/ui/Dockerfile \
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.UI_IMAGE }}:main' \
.
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:main'
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.MCP_SERVER_IMAGE }}:main'
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.UI_IMAGE }}:main'
- name: Configure SSH key
run: |
. "$OPENBAO_ENV_FILE"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Configure known hosts
run: |
. "$OPENBAO_ENV_FILE"
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
else
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts
fi
chmod 644 ~/.ssh/known_hosts
- name: Sync deployment files to server
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH'"
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
- name: Write environment file
run: |
. "$OPENBAO_ENV_FILE"
tmp_env="$(mktemp)"
append_if_set() {
if [ -n "$2" ]; then
printf '%s=%s\n' "$1" "$2" >> "$tmp_env"
fi
}
: > "$tmp_env"
append_if_set POSTGRES_DB "$POSTGRES_DB"
append_if_set POSTGRES_USER "$POSTGRES_USER"
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
if [ -n "${POSTGRES_PORT:-}" ]; then
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
elif [ -n "${PGBOUNCER_PORT:-}" ]; then
append_if_set POSTGRES_PORT "$PGBOUNCER_PORT"
fi
append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT"
append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND"
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_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_LOG_LEVEL "$CRANK_LOG_LEVEL"
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL"
append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS"
append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET"
append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER"
append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS"
append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL"
append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD"
append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME"
append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED"
{
printf 'COMPOSE_PROJECT_NAME=community\n'
printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}'
printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}'
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
} >> "$tmp_env"
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH' && cat > '$DEPLOY_PATH/.env'"
rm -f "$tmp_env"
- name: Validate required environment variables
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
set -e
cd '$DEPLOY_PATH'
required_vars='
POSTGRES_HOST
POSTGRES_PORT
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
CRANK_MASTER_KEY
CRANK_SESSION_SECRET
CRANK_PASSWORD_PEPPER
CRANK_BOOTSTRAP_ADMIN_EMAIL
CRANK_BOOTSTRAP_ADMIN_PASSWORD
CRANK_BASE_URL
'
for var in \$required_vars; do
value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true)
if [ -z \"\$value\" ]; then
echo \"missing required env: \$var\" >&2
exit 1
fi
done
"
- name: Deploy with Docker Compose
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
set -e
cd '$DEPLOY_PATH'
compose_profiles=''
cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true)
if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then
compose_profiles='--profile cache'
fi
echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin
docker compose \$compose_profiles config -q
docker compose \$compose_profiles pull
docker compose \$compose_profiles down --remove-orphans
for container in \
crank-ui-1 \
crank-admin-api-1 \
crank-mcp-server-1 \
crank-postgres-1 \
crank-valkey-1 \
crank-community-ui-1 \
crank-community-admin-api-1 \
crank-community-mcp-server-1 \
crank-community-postgres-1 \
crank-community-valkey-1; do
if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then
docker rm -f \"\$container\"
fi
done
echo 'Docker containers before freeing required ports:'
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'
for port in 3000 3001 3002; do
container_ids=\$(docker ps -aq --filter \"publish=\$port\")
if [ -n \"\$container_ids\" ]; then
echo \"Removing containers publishing port \$port\"
docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true
docker rm -f \$container_ids
fi
done
if command -v ss >/dev/null 2>&1; then
ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true
fi
docker compose \$compose_profiles up -d --remove-orphans
"
- name: Verify health endpoints
run: |
. "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
set -e
cd '$DEPLOY_PATH'
for attempt in \$(seq 1 30); do
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
&& curl --fail --silent http://127.0.0.1:3001/health >/dev/null \
&& curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then
exit 0
fi
sleep 2
done
echo 'deployment health verification failed' >&2
docker compose ps >&2
exit 1
"
- name: Run authenticated product smoke
run: |
. "$OPENBAO_ENV_FILE"
CRANK_STAGING_ADMIN_EMAIL="$CRANK_BOOTSTRAP_ADMIN_EMAIL" \
CRANK_STAGING_ADMIN_PASSWORD="$CRANK_BOOTSTRAP_ADMIN_PASSWORD" \
scripts/authenticated-product-smoke.sh "$CRANK_BASE_URL"
+17
View File
@@ -22,6 +22,23 @@ jobs:
- name: Checkout
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
run: |
rustc --version
Generated
+562 -869
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -18,28 +18,28 @@ resolver = "3"
[workspace.package]
edition = "2024"
license = "AGPL-3.0-only"
rust-version = "1.85"
rust-version = "1.96"
version = "0.3.1"
[workspace.dependencies]
aes-gcm = "0.10"
argon2 = "0.5"
axum = "0.8"
axum-extra = { version = "0.10", features = ["cookie"] }
axum-extra = { version = "0.12", features = ["cookie"] }
base64 = "0.22"
hkdf = "0.12"
rand = "0.8"
rand = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
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"
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"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1", features = ["serde", "v7"] }
testcontainers = { version = "0.25.0", features = ["blocking"] }
testcontainers-modules = { version = "0.13.0", features = ["postgres", "blocking"] }
testcontainers = { version = "0.27", features = ["blocking"] }
testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] }
+2 -2
View File
@@ -1,4 +1,4 @@
FROM rust:1.85-bookworm AS deps
FROM rust:1.96.1-bookworm AS deps
WORKDIR /app
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
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
+6 -1
View File
@@ -19,7 +19,10 @@ use crate::{
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
capabilities::get_capabilities,
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::{
analyze_operation_quality, archive_operation, create_operation, create_version,
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("/logs", get(list_logs))
.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/operations/{operation_id}", get(get_operation_usage))
.route("/usage/agents/{agent_id}", get(get_agent_usage));
+20 -4
View File
@@ -1,8 +1,8 @@
use crank_core::{
AgentId, AgentStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft,
InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus,
PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod, WizardState, WorkspaceId,
WorkspaceStatus,
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::MappingSet;
use crank_registry::{
@@ -211,7 +211,17 @@ pub struct AgentMutationResult {
#[derive(Clone, Debug, Deserialize)]
pub struct PlatformApiKeyPayload {
pub name: String,
#[serde(default = "default_platform_api_key_kind")]
pub key_kind: PlatformApiKeyKind,
pub scopes: Vec<PlatformApiKeyScope>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub allowed_origins: Vec<String>,
}
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
PlatformApiKeyKind::McpClient
}
#[derive(Clone, Debug, Serialize)]
@@ -240,6 +250,12 @@ pub struct LogsQuery {
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ApprovalsQuery {
pub status: Option<ApprovalRequestStatus>,
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UsageRequestQuery {
pub period: Option<UsagePeriod>,
+1
View File
@@ -175,6 +175,7 @@ mod tests {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+33 -1
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use crate::{
error::ApiError,
routes::access::WorkspacePath,
service::{LogsQuery, UsageRequestQuery},
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
state::AppState,
};
@@ -17,6 +17,12 @@ pub struct WorkspaceLogPath {
pub log_id: String,
}
#[derive(serde::Deserialize)]
pub struct WorkspaceApprovalPath {
pub workspace_id: String,
pub approval_id: String,
}
#[derive(serde::Deserialize)]
pub struct WorkspaceOperationUsagePath {
pub workspace_id: String,
@@ -55,6 +61,32 @@ pub async fn get_log(
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(
Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
+4 -1
View File
@@ -38,7 +38,8 @@ mod workspaces;
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
use operation_validation::{
validate_idempotency_policy, validate_protocol_target, validate_response_cache_policy,
validate_approval_policy, validate_idempotency_policy, validate_protocol_target,
validate_response_cache_policy,
};
#[derive(Clone)]
@@ -298,6 +299,7 @@ impl AdminService {
validate_protocol_target(payload.protocol, &payload.target)?;
validate_response_cache_policy(&payload.target, &payload.execution_config)?;
validate_idempotency_policy(&payload.target, &payload.execution_config)?;
validate_approval_policy(&payload.execution_config)?;
payload.input_mapping.validate_paths()?;
payload.output_mapping.validate_paths()?;
Ok(())
@@ -308,6 +310,7 @@ impl AdminService {
validate_protocol_target(operation.protocol, &operation.target)?;
validate_response_cache_policy(&operation.target, &operation.execution_config)?;
validate_idempotency_policy(&operation.target, &operation.execution_config)?;
validate_approval_policy(&operation.execution_config)?;
operation.input_mapping.validate_paths()?;
operation.output_mapping.validate_paths()?;
Ok(())
+56 -3
View File
@@ -1,7 +1,10 @@
use crank_core::{AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, WorkspaceId};
use crank_core::{
AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, WorkspaceId,
};
use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
use serde_json::json;
use time::OffsetDateTime;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument;
use crate::{
@@ -53,7 +56,19 @@ impl AdminService {
)
})?;
let secret = generate_access_secret("crk");
validate_platform_api_key_payload(&payload)?;
let expires_at = match payload.expires_at.as_deref() {
Some(value) => Some(
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?,
),
None => None,
};
let secret = generate_access_secret(match payload.key_kind {
PlatformApiKeyKind::McpClient => "crk",
PlatformApiKeyKind::Approval => "crk_appr",
});
let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
@@ -61,10 +76,13 @@ impl AdminService {
agent_id: Some(agent_id.clone()),
name: payload.name,
prefix: secret.chars().take(16).collect(),
key_kind: payload.key_kind,
scopes: payload.scopes,
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at,
allowed_origins: payload.allowed_origins,
},
};
@@ -109,3 +127,38 @@ impl AdminService {
Ok(())
}
}
fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("key name is required"));
}
if payload.scopes.is_empty() {
return Err(ApiError::validation("at least one key scope is required"));
}
let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
PlatformApiKeyKind::McpClient => matches!(
scope,
PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy
),
PlatformApiKeyKind::Approval => matches!(
scope,
PlatformApiKeyScope::Approve
| PlatformApiKeyScope::Deny
| PlatformApiKeyScope::ReadPending
),
});
if !valid {
return Err(ApiError::validation(
"key scopes do not match selected key kind",
));
}
if payload.key_kind == PlatformApiKeyKind::Approval && payload.allowed_origins.len() > 20 {
return Err(ApiError::validation(
"approval key can contain at most 20 allowed origins",
));
}
Ok(())
}
+33 -13
View File
@@ -2,12 +2,12 @@ use std::collections::BTreeMap;
use crank_core::{
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationSecurityLevel, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, Target,
WizardState, WorkspaceId,
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, Target, WizardState, WorkspaceId,
};
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{ListInvocationLogsQuery, OperationSummary, SampleKind};
use crank_registry::{ListInvocationLogsQuery, OperationSummary, RegistryError, SampleKind};
use crank_schema::Schema;
use serde_json::{Value, json};
@@ -99,11 +99,8 @@ impl AdminService {
.name
.starts_with("weather_current_open_meteo_smoke_")
{
self.delete_operation(
workspace_id,
&OperationId::new(operation.id.as_str().to_owned()),
)
.await?;
self.delete_legacy_demo_operation_if_safe(workspace_id, &operation.id)
.await?;
}
}
@@ -113,17 +110,36 @@ impl AdminService {
"weather_current_open_meteo",
] {
if let Some(operation) = self.find_operation_by_name(workspace_id, name).await? {
self.delete_operation(
workspace_id,
&OperationId::new(operation.id.as_str().to_owned()),
)
.await?;
self.delete_legacy_demo_operation_if_safe(workspace_id, &operation.id)
.await?;
}
}
Ok(())
}
async fn delete_legacy_demo_operation_if_safe(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<(), ApiError> {
match self
.registry
.delete_operation(workspace_id, operation_id)
.await
{
Ok(()) => Ok(()),
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => {
tracing::warn!(
operation_id = %operation_id.as_str(),
"legacy demo operation is still bound to a published agent; leaving it in place"
);
Ok(())
}
Err(error) => Err(ApiError::from(error)),
}
}
async fn ensure_demo_platform_api_key(
&self,
workspace_id: &WorkspaceId,
@@ -146,7 +162,10 @@ impl AdminService {
agent_id,
PlatformApiKeyPayload {
name: name.to_owned(),
key_kind: PlatformApiKeyKind::McpClient,
scopes,
expires_at: None,
allowed_origins: Vec::new(),
},
)
.await?
@@ -368,6 +387,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+1
View File
@@ -191,6 +191,7 @@ impl AdminService {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+83 -3
View File
@@ -1,11 +1,21 @@
use crank_core::{AgentId, InvocationLogId, OperationId, UsagePeriod, WorkspaceId};
use crank_registry::{InvocationLogRecord, ListInvocationLogsQuery, UsageQuery, UsageRollupRecord};
use crank_core::{
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
WorkspaceId,
};
use crank_registry::{
ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery,
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord,
};
use serde_json::json;
use time::OffsetDateTime;
use tracing::instrument;
use crate::{
error::ApiError,
service::{AdminService, LogsQuery, UsageOverviewResponse, UsageRequestQuery, usage_window},
service::{
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery,
usage_window,
},
};
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))]
pub async fn get_usage_overview(
&self,
@@ -105,16 +105,53 @@ pub(super) fn validate_idempotency_policy(
Ok(())
}
pub(super) fn validate_approval_policy(
execution_config: &crank_core::ExecutionConfig,
) -> Result<(), ApiError> {
let Some(policy) = execution_config.approval_policy.as_ref() else {
return Ok(());
};
if !policy.required {
return Ok(());
}
if policy.ttl_seconds == 0 || policy.ttl_seconds > 300 {
return Err(ApiError::validation_with_context(
"approval ttl must be between 1 and 300 seconds".to_owned(),
json!({
"field": "execution_config.approval_policy.ttl_seconds",
}),
));
}
if let Some(message) = policy.elicitation_message.as_ref()
&& message.chars().count() > 240
{
return Err(ApiError::validation_with_context(
"approval elicitation message must be at most 240 characters".to_owned(),
json!({
"field": "execution_config.approval_policy.elicitation_message",
}),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use crank_core::{
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, ResponseCachePolicy,
RestTarget, Target,
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, OperationApprovalMode,
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
ResponseCachePolicy, RestTarget, Target,
};
use super::{validate_idempotency_policy, validate_response_cache_policy};
use super::{
validate_approval_policy, validate_idempotency_policy, validate_response_cache_policy,
};
fn cacheable_execution_config() -> ExecutionConfig {
ExecutionConfig {
@@ -123,6 +160,7 @@ mod tests {
response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }),
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
}
@@ -140,6 +178,7 @@ mod tests {
header_name: Some("Idempotency-Key".to_owned()),
}),
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
}
@@ -238,4 +277,42 @@ mod tests {
"required idempotency needs input_field or header_name"
);
}
#[test]
fn accepts_valid_approval_policy() {
let mut config = cacheable_execution_config();
config.approval_policy = Some(OperationApprovalPolicy {
required: true,
mode: OperationApprovalMode::Custom,
risk_level: OperationApprovalRiskLevel::Dangerous,
ttl_seconds: 300,
show_payload_preview: true,
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
elicitation_message: None,
});
validate_approval_policy(&config).unwrap();
}
#[test]
fn rejects_invalid_approval_policy() {
let mut config = cacheable_execution_config();
config.approval_policy = Some(OperationApprovalPolicy {
required: true,
mode: OperationApprovalMode::Custom,
risk_level: OperationApprovalRiskLevel::Dangerous,
ttl_seconds: 0,
show_payload_preview: true,
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
elicitation_message: None,
});
let error = validate_approval_policy(&config).unwrap_err();
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
assert_eq!(
error.to_string(),
"approval ttl must be between 1 and 300 seconds"
);
}
}
@@ -297,6 +297,7 @@ pub(super) fn test_operation_payload(base_url: &str, name: &str) -> OperationPay
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -12,8 +12,8 @@ use std::{
use async_trait::async_trait;
use axum::{Json, Router, routing::post};
use crank_core::{
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
AgentId, ExecutionConfig, HttpMethod, MembershipRole, OperationId, OperationSecurityLevel,
Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
};
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
use crank_mapping::{MappingRule, MappingSet};
@@ -27,7 +27,9 @@ use tokio::net::TcpListener;
use admin_api::{
app::build_app,
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
service::{AdminService, AdminServiceBuilder, OperationPayload},
service::{
AdminService, AdminServiceBuilder, AgentBindingPayload, AgentPayload, OperationPayload,
},
state::AppState,
};
@@ -151,6 +153,7 @@ async fn manages_agent_platform_api_keys() {
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "sales-routing-primary",
"key_kind": "mcp_client",
"scopes": ["read", "write"]
}))
.send()
@@ -162,6 +165,31 @@ async fn manages_agent_platform_api_keys() {
.as_str()
.unwrap()
.to_owned();
let created_approval_key = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "sales-routing-approver",
"key_kind": "approval",
"scopes": ["approve", "deny"],
"allowed_origins": ["https://client.example.test"]
}))
.send()
.await
.unwrap(),
)
.await;
let invalid_mixed_scope_status = client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "invalid-mixed-scope",
"key_kind": "approval",
"scopes": ["read", "approve"]
}))
.send()
.await
.unwrap()
.status();
let listed_keys = assert_success_json(
client
@@ -190,14 +218,27 @@ async fn manages_agent_platform_api_keys() {
.status();
assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id);
assert_eq!(created_key["api_key"]["api_key"]["key_kind"], "mcp_client");
assert_eq!(
created_approval_key["api_key"]["api_key"]["key_kind"],
"approval"
);
assert!(
created_approval_key["secret"]
.as_str()
.unwrap()
.starts_with("crk_appr_")
);
assert_eq!(
created_approval_key["api_key"]["api_key"]["allowed_origins"],
json!(["https://client.example.test"])
);
assert_eq!(invalid_mixed_scope_status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(
listed_keys["items"][0]["api_key"]["agent_id"],
json!(agent_id)
);
assert_eq!(
listed_keys["items"][0]["api_key"]["name"],
"sales-routing-primary"
);
assert_eq!(listed_keys["items"].as_array().unwrap().len(), 2);
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT);
@@ -398,6 +439,61 @@ async fn seeds_demo_assets_for_live_ui() {
service.bootstrap_admin_user().await.unwrap();
service.seed_demo_assets().await.unwrap();
let smoke_operation = service
.create_operation(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
test_operation_payload("https://example.test", "internal_health_smoke_bound"),
)
.await
.unwrap();
let smoke_operation_id = OperationId::new(smoke_operation.operation_id);
service
.publish_operation(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&smoke_operation_id,
smoke_operation.version,
)
.await
.unwrap();
let smoke_agent = service
.create_agent(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
AgentPayload {
slug: "legacy-smoke-agent".to_owned(),
display_name: "Legacy Smoke Agent".to_owned(),
description: "Keeps a legacy smoke operation published".to_owned(),
instructions: json!({}),
tool_selection_policy: json!({}),
},
)
.await
.unwrap();
let smoke_agent_id = AgentId::new(smoke_agent.agent_id);
service
.save_agent_bindings(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&smoke_agent_id,
vec![AgentBindingPayload {
operation_id: smoke_operation_id.as_str().to_owned(),
operation_version: smoke_operation.version,
tool_name: "legacy_health_smoke".to_owned(),
tool_title: "Legacy health smoke".to_owned(),
tool_description_override: None,
enabled: true,
}],
)
.await
.unwrap();
service
.publish_agent(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&smoke_agent_id,
smoke_agent.version,
)
.await
.unwrap();
service.seed_demo_assets().await.unwrap();
let owner = registry
@@ -431,15 +527,18 @@ async fn seeds_demo_assets_for_live_ui() {
.iter()
.any(|operation| operation.name == "crm_create_lead")
);
assert!(!operations.iter().any(
|operation| operation.name.starts_with("internal_health_smoke_")
&& operation.name != "internal_health_smoke_bound"
));
assert!(
!operations
operations
.iter()
.any(|operation| operation.name.starts_with("internal_health_smoke_"))
.any(|operation| operation.name == "internal_health_smoke_bound")
);
let agents = service.list_agents(&default_workspace_id).await.unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].slug, "currency-rates");
assert!(agents.iter().any(|agent| agent.slug == "currency-rates"));
assert!(agents.iter().any(|agent| agent.key_count > 0));
+2 -2
View File
@@ -1,4 +1,4 @@
FROM rust:1.85-bookworm AS deps
FROM rust:1.96.1-bookworm AS deps
WORKDIR /app
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
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
@@ -1,5 +1,7 @@
#![allow(dead_code, unused_imports)]
mod approval_access;
use super::common::*;
use std::{
@@ -17,14 +19,18 @@ use axum::{
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, InvocationSource,
Operation, OperationApprovalMode, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy,
OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
PublishAgentRequest, PublishRequest, SaveAgentBindingsRequest,
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest,
ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest,
SaveAgentBindingsRequest,
};
use crank_runtime::{
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
@@ -476,6 +482,38 @@ async fn rejects_initialize_without_platform_api_key() {
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_initialize_with_approval_platform_api_key() {
let registry = test_registry().await;
publish_agent_with_bindings(&registry, "sales-approval-key", vec![]).await;
let api_key =
create_approval_platform_api_key(&registry, "sales-approval-key", "approval-only").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 response = client
.post(agent_mcp_url(&base_url, "sales-approval-key"))
.header(header::ACCEPT, "application/json, text/event-stream")
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25"
}
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_tool_call_with_read_only_platform_api_key() {
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(
&registry,
"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(&registry, "sales-human-approval", "approval-http").await;
let mcp_key = create_platform_api_key(
&registry,
"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(
&registry,
"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(&registry, "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(
&registry,
"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(&registry, "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(&registry, &operation, "sales-gated").await;
let api_key = create_platform_api_key(
&registry,
"sales-gated",
"mcp-gated",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let approval_key =
create_approval_platform_api_key(&registry, "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(
&registry,
"sales-elicitation-no-capability",
vec![binding_for_operation(&operation)],
)
.await;
let api_key = create_platform_api_key(
&registry,
"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(
&registry,
"sales-elicitation-supported",
vec![binding_for_operation(&operation)],
)
.await;
let api_key = create_platform_api_key(
&registry,
"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"
);
}
+58 -4
View File
@@ -16,8 +16,9 @@ use axum::{
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
@@ -146,6 +147,15 @@ pub(super) async fn initialize_session(
client: &reqwest::Client,
mcp_url: &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 {
let initialize_response = client
.post(mcp_url)
@@ -156,7 +166,8 @@ pub(super) async fn initialize_session(
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25"
"protocolVersion": "2025-11-25",
"capabilities": capabilities
}
}))
.send()
@@ -236,17 +247,59 @@ pub(super) async fn create_platform_api_key(
name: &str,
scopes: &[PlatformApiKeyScope],
) -> String {
let secret = format!("crk_{}_{}", name, uuid::Uuid::now_v7().simple());
create_platform_api_key_with_kind(
registry,
agent_slug,
name,
PlatformApiKeyKind::McpClient,
scopes,
"crk",
)
.await
}
pub(super) async fn create_approval_platform_api_key(
registry: &PostgresRegistry,
agent_slug: &str,
name: &str,
) -> String {
create_platform_api_key_with_kind(
registry,
agent_slug,
name,
PlatformApiKeyKind::Approval,
&[
PlatformApiKeyScope::ReadPending,
PlatformApiKeyScope::Approve,
PlatformApiKeyScope::Deny,
],
"crk_appr",
)
.await
}
async fn create_platform_api_key_with_kind(
registry: &PostgresRegistry,
agent_slug: &str,
name: &str,
key_kind: PlatformApiKeyKind,
scopes: &[PlatformApiKeyScope],
prefix: &str,
) -> String {
let secret = format!("{prefix}_{}_{}", name, uuid::Uuid::now_v7().simple());
let api_key = PlatformApiKey {
id: PlatformApiKeyId::new(format!("pk_{name}")),
workspace_id: test_workspace_id(),
agent_id: Some(test_agent_id(agent_slug)),
key_kind,
name: name.to_owned(),
prefix: secret.chars().take(16).collect(),
scopes: scopes.to_vec(),
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
@@ -444,6 +497,7 @@ pub(super) fn test_operation(base_url: &str, name: &str) -> Operation<Schema, Ma
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -689,6 +689,7 @@ async fn get_returns_not_found_for_expired_transport_session() {
"2025-11-25",
test_workspace_slug(),
"sales-expired-session",
false,
OffsetDateTime::parse("2026-05-01T10:00:00Z", &Rfc3339).unwrap(),
Some(OffsetDateTime::parse("2026-05-01T10:00:01Z", &Rfc3339).unwrap()),
)
+185
View File
@@ -134,3 +134,188 @@
}
.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;
}
}
+69 -20
View File
@@ -12,28 +12,36 @@
.openapi-import-modal {
position: fixed;
inset: 0;
z-index: 1200;
z-index: 2400;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 28px 16px;
overflow: auto;
isolation: isolate;
}
.openapi-import-backdrop {
position: absolute;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.52);
backdrop-filter: blur(5px);
z-index: 0;
background: rgba(1, 4, 9, 0.82);
backdrop-filter: blur(8px);
}
.openapi-import-dialog {
position: relative;
z-index: 1;
width: min(1040px, calc(100vw - 32px));
max-height: min(860px, calc(100vh - 32px));
margin: 16px auto;
max-height: calc(100vh - 56px);
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 22px;
background: var(--surface);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
background: var(--bg-canvas);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
}
.openapi-import-header {
@@ -42,6 +50,7 @@
gap: 18px;
padding: 22px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-canvas);
}
.openapi-import-header h2 {
@@ -58,11 +67,16 @@
.openapi-import-body {
overflow: auto;
padding: 20px 24px 24px;
background: var(--bg-canvas);
}
.openapi-import-upload {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: 16px;
background: var(--bg-surface);
}
.openapi-file-label {
@@ -74,18 +88,53 @@
}
#openapi-import-document {
min-height: 180px;
min-height: 132px;
max-height: 34vh;
resize: vertical;
padding: 14px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface-muted);
background: #0d1117;
color: var(--text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
}
#openapi-import-file {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.openapi-file-control {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.openapi-file-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
cursor: pointer;
}
.openapi-file-name {
overflow: hidden;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.openapi-import-actions,
.openapi-import-footer {
display: flex;
@@ -115,7 +164,7 @@
.openapi-import-group {
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface-muted);
background: var(--bg-overlay);
}
.openapi-import-source {
@@ -144,7 +193,7 @@
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
background: var(--bg-surface);
color: var(--text-primary);
}
@@ -156,7 +205,7 @@
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface-muted);
background: var(--bg-overlay);
}
.openapi-import-filter {
@@ -174,7 +223,7 @@
#openapi-import-method-filter {
width: 100%;
max-width: none;
background: var(--surface);
background: var(--bg-surface);
}
.openapi-import-bulk-actions {
@@ -242,7 +291,7 @@
.openapi-import-method {
padding: 4px 8px;
border-radius: 999px;
background: var(--accent-muted);
background: var(--accent-glow);
color: var(--accent);
font-size: 11px;
font-weight: 800;
@@ -282,7 +331,7 @@
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
background: var(--bg-surface);
}
.openapi-import-mapping-group {
@@ -304,7 +353,7 @@
padding: 3px 7px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-muted);
background: var(--bg-overlay);
color: var(--text-primary);
font-size: 11px;
}
@@ -322,7 +371,7 @@
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface-muted);
background: var(--bg-overlay);
color: var(--text-secondary);
font-size: 13px;
}
@@ -347,7 +396,7 @@
overflow: hidden;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
background: var(--bg-surface);
}
.openapi-import-result-row {
@@ -369,7 +418,7 @@
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--surface-muted);
background: var(--bg-overlay);
}
.openapi-import-result-name {
+93 -1
View File
@@ -202,6 +202,7 @@
display: grid;
grid-template-columns:
minmax(130px, 1fr)
24px
minmax(105px, 0.65fr)
minmax(130px, 1fr)
minmax(120px, 0.8fr)
@@ -216,14 +217,62 @@
}
.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 {
width: 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;
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 {
@@ -315,6 +364,12 @@
grid-template-columns: 1fr;
}
.mapping-arrow {
width: 100%;
height: 18px;
transform: rotate(90deg);
}
.mapping-row-remove {
width: 100%;
}
@@ -1058,6 +1113,31 @@
.toggle-label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.toggle-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 1px; }
.approval-toggle-row {
position: relative;
}
.approval-toggle-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.approval-config-fields {
display: grid;
gap: 16px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: 10px;
background: rgba(13, 17, 23, 0.42);
}
.approval-preview-pill {
align-self: end;
min-height: 38px;
justify-content: center;
}
/*
BOTTOM ACTION BAR frosted dark glass
*/
@@ -1250,6 +1330,18 @@
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 {
display: flex;
+86 -25
View File
@@ -23,9 +23,75 @@
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
.keys-card-list { display: none; }
.key-kind-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-overlay);
}
.key-kind-tab {
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
}
.key-kind-tab.active {
background: var(--accent);
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 {
display: flex;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--amber-border);
border-radius: var(--radius-lg);
background: var(--amber-bg);
color: var(--text-primary);
font-size: 13px;
line-height: 1.55;
}
.approval-warning-callout svg {
color: var(--amber);
flex: 0 0 auto;
margin-top: 2px;
}
@media (max-width: 720px) {
#keys-table-wrap { display: none; }
.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-tab { flex: 1; }
.key-kind-header-hint { text-align: left; }
}
</style>
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
@@ -94,16 +160,23 @@
<!-- ═══════════════════ PAGE ═══════════════════ -->
<div class="page">
<div class="page-header">
<div class="page-header api-keys-page-header">
<div class="page-header-text">
<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>
</div>
<div class="page-header-actions">
<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 data-i18n="apikeys.new">Create key</span>
</button>
<div class="key-kind-header-control">
<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>
<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>
@@ -170,25 +243,7 @@
<div class="section-card-header">
<div class="section-card-title" data-i18n="apikeys.scope_ref">Access reference</div>
</div>
<div class="section-card-body" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;display:flex;align-items:center;gap:6px;">
<span class="badge badge-scope">read</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.read">Initialize MCP sessions, ping the server, and list tools for a workspace agent.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">write</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.write">Execute `tools/call` requests against published agent toolsets.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">deploy</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.deploy">Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.</div>
</div>
<div class="section-card-body" id="scope-reference-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
</div>
</div>
@@ -198,7 +253,7 @@
<div class="modal-overlay" id="modal-create">
<div class="modal">
<div class="modal-header">
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
<span class="modal-title" id="modal-create-title" data-i18n="apikeys.modal.title">Create agent key</span>
<button class="modal-close" id="modal-close-btn" type="button">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
@@ -211,6 +266,12 @@
<input class="field-input" id="new-key-name" type="text" data-i18n-ph="apikeys.modal.name_placeholder" placeholder="e.g. Production, CI pipeline" autocomplete="off">
<div class="field-hint" data-i18n="apikeys.modal.name_hint">A descriptive label to identify the key. Only visible to admins.</div>
</div>
<div class="approval-warning-callout" id="approval-key-warning" hidden>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
</svg>
<div data-i18n="apikeys.approval.warning">Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.</div>
</div>
<div class="field-group" style="margin-bottom:0;">
<label class="field-label" data-i18n="apikeys.modal.scopes">Access</label>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
+14
View File
@@ -78,6 +78,20 @@
</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="log-toolbar">
<div class="live-dot"></div>
+88
View File
@@ -90,4 +90,92 @@
</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 -->
+4
View File
@@ -392,6 +392,10 @@
<div class="openapi-import-upload">
<label class="openapi-file-label">
<span>Файл OpenAPI/Swagger</span>
<span class="openapi-file-control">
<span class="btn-secondary openapi-file-button">Выбрать файл</span>
<span class="openapi-file-name" id="openapi-import-file-name">Файл не выбран</span>
</span>
<input id="openapi-import-file" type="file" accept=".yaml,.yml,.json,application/json,text/yaml">
</label>
<textarea id="openapi-import-document" spellcheck="false" placeholder="Вставьте openapi.yaml или swagger.json"></textarea>
+82 -8
View File
@@ -2,14 +2,21 @@ var KEYS = [];
var AGENTS = [];
var currentWorkspaceId = null;
var currentAgentId = null;
var activeKeyKind = 'mcp_client';
var selectedScopes = new Set(['read']);
var search = '';
var SCOPES = ['read', 'write', 'deploy'];
var SCOPES_BY_KIND = {
mcp_client: ['read', 'write', 'deploy'],
approval: ['read_pending', 'approve', 'deny'],
};
var SCOPE_DESC = {
read: 'apikeys.scope.read',
write: 'apikeys.scope.write',
deploy: 'apikeys.scope.deploy',
approve: 'apikeys.scope.approve',
deny: 'apikeys.scope.deny',
read_pending: 'apikeys.scope.read_pending',
};
function tKey(key) {
@@ -35,6 +42,7 @@ function mapKeyRecord(record) {
return {
id: apiKey.id,
agentId: apiKey.agent_id || null,
keyKind: apiKey.key_kind || 'mcp_client',
name: apiKey.name,
prefix: apiKey.prefix || '',
scopes: apiKey.scopes || [],
@@ -173,6 +181,7 @@ async function deleteKey(id) {
async function createKey(name, scopes) {
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
name: name,
key_kind: activeKeyKind,
scopes: scopes,
});
return {
@@ -221,13 +230,19 @@ function setCreateButtonState() {
var button = document.getElementById('btn-create-key');
if (!button) return;
button.disabled = !currentAgentId;
var label = document.getElementById('btn-create-key-label');
if (label) {
label.textContent = activeKeyKind === 'approval'
? tKey('apikeys.new_approval')
: tKey('apikeys.new_mcp');
}
}
function renderScopes() {
var el = document.getElementById('scope-checkboxes');
var tmpl = document.getElementById('tmpl-scope-checkbox');
el.innerHTML = '';
SCOPES.forEach(function(scope) {
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
var node = tmpl.content.cloneNode(true);
var input = node.querySelector('input');
input.dataset.scope = scope;
@@ -242,11 +257,49 @@ function renderScopes() {
});
}
function renderKeyKindTabs() {
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
var kind = button.dataset.keyKind;
button.classList.toggle('active', kind === activeKeyKind);
button.setAttribute('aria-selected', kind === activeKeyKind ? 'true' : 'false');
});
var hint = document.getElementById('key-kind-hint');
if (hint) {
hint.textContent = activeKeyKind === 'approval'
? tKey('apikeys.kind.approval_hint')
: tKey('apikeys.kind.mcp_hint');
}
renderScopeReference();
setCreateButtonState();
}
function renderScopeReference() {
var grid = document.getElementById('scope-reference-grid');
if (!grid) return;
grid.innerHTML = '';
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
var item = document.createElement('div');
var title = document.createElement('div');
title.style.cssText = 'font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;';
var badge = document.createElement('span');
badge.className = 'badge badge-scope';
badge.textContent = scope;
title.appendChild(badge);
var description = document.createElement('div');
description.style.cssText = 'font-size:12px;color:var(--text-muted);line-height:1.55;';
description.textContent = tKey(SCOPE_DESC[scope]);
item.appendChild(title);
item.appendChild(description);
grid.appendChild(item);
});
}
function renderTable(errorMessage) {
var q = search.toLowerCase();
var tbody = document.getElementById('keys-tbody');
var cardList = document.getElementById('keys-card-list');
var rows = KEYS.filter(function(key) {
var visibleKeys = KEYS.filter(function(key) { return key.keyKind === activeKeyKind; });
var rows = visibleKeys.filter(function(key) {
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
});
var subtitle = document.getElementById('keys-summary-subtitle');
@@ -257,8 +310,8 @@ function renderTable(errorMessage) {
}
if (subtitle) {
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
var active = visibleKeys.filter(function(key) { return key.status === 'active'; }).length;
var revoked = visibleKeys.filter(function(key) { return key.status === 'revoked'; }).length;
subtitle.textContent = currentAgentId
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
: tKey('apikeys.agent.empty_hint');
@@ -282,7 +335,7 @@ function renderTable(errorMessage) {
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = currentAgentId
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint');
empty.appendChild(td);
tbody.appendChild(empty);
@@ -349,7 +402,7 @@ function renderKeyCards(rows, errorMessage) {
cardList.appendChild(
buildKeyCardMessage(
currentAgentId
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint'),
false
)
@@ -424,6 +477,12 @@ function buildKeyCardMessage(text, isError) {
return card;
}
function emptyTextForKind() {
return activeKeyKind === 'approval'
? tKey('apikeys.empty.approval')
: tKey('apikeys.empty.none');
}
function buildMetaItem(labelKey, valueText) {
var item = document.createElement('div');
item.className = 'resource-meta-item';
@@ -456,7 +515,11 @@ function openModal() {
document.getElementById('modal-footer-create').hidden = false;
document.getElementById('modal-footer-done').hidden = true;
document.getElementById('new-key-name').value = '';
selectedScopes = new Set(['read']);
selectedScopes = new Set([activeKeyKind === 'approval' ? 'approve' : 'read']);
document.getElementById('modal-create-title').textContent = activeKeyKind === 'approval'
? tKey('apikeys.modal.title_approval')
: tKey('apikeys.modal.title_mcp');
document.getElementById('approval-key-warning').hidden = activeKeyKind !== 'approval';
renderScopes();
modal.classList.add('open');
setTimeout(function() {
@@ -554,6 +617,16 @@ document.getElementById('agent-select').addEventListener('change', async functio
await loadKeys();
});
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
button.addEventListener('click', function() {
activeKeyKind = this.dataset.keyKind || 'mcp_client';
search = '';
document.getElementById('key-search').value = '';
renderKeyKindTabs();
renderTable();
});
});
function copyPrefix(prefix) {
if (navigator.clipboard) {
navigator.clipboard.writeText(prefix).catch(function() {});
@@ -564,6 +637,7 @@ function copyPrefix(prefix) {
}
document.addEventListener('DOMContentLoaded', async function() {
renderKeyKindTabs();
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
await loadKeys();
window.addEventListener('crank:workspacechange', function() {
+6
View File
@@ -327,6 +327,12 @@
getLog: function(workspaceId, 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) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
},
+104
View File
@@ -95,6 +95,12 @@ var TRANSLATIONS = {
'apikeys.title': 'Agent Keys',
'apikeys.subtitle': 'These keys connect an MCP client to the MCP server and are issued for a specific agent.',
'apikeys.new': 'Create key',
'apikeys.new_mcp': 'Create MCP client key',
'apikeys.new_approval': 'Create approval key',
'apikeys.kind.mcp': 'MCP clients',
'apikeys.kind.approval': 'Approvals',
'apikeys.kind.mcp_hint': 'MCP client keys connect an agent to tools/list and tools/call.',
'apikeys.kind.approval_hint': 'Approval keys are used only by an external human confirmation interface.',
'apikeys.agent.title': 'Agent selection',
'apikeys.agent.subtitle': 'Select the AI agent this key is issued for.',
'apikeys.agent.label': 'AI agent',
@@ -118,7 +124,12 @@ var TRANSLATIONS = {
'apikeys.scope.read': 'Initialize MCP sessions, ping the server, and list tools for the selected AI agent.',
'apikeys.scope.write': 'Execute `tools/call` requests against published agent toolsets.',
'apikeys.scope.deploy': 'Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.',
'apikeys.scope.approve': 'Confirm a pending human approval request for this agent.',
'apikeys.scope.deny': 'Reject a pending human approval request for this agent.',
'apikeys.scope.read_pending': 'Read pending human approval requests for this agent.',
'apikeys.modal.title': 'Create agent key',
'apikeys.modal.title_mcp': 'Create MCP client key',
'apikeys.modal.title_approval': 'Create approval key',
'apikeys.modal.name': 'Key name',
'apikeys.modal.name_hint': 'A descriptive label to identify the key. Only visible to admins.',
'apikeys.modal.name_placeholder': 'e.g. Production, CI pipeline',
@@ -133,6 +144,7 @@ var TRANSLATIONS = {
'apikeys.status.revoked': 'Revoked',
'apikeys.last_used.never': 'Never',
'apikeys.empty.none': 'No API keys yet',
'apikeys.empty.approval': 'No approval keys yet. Create one only if this agent has tools that require human confirmation.',
'apikeys.empty.search': 'No keys match your search',
'apikeys.loading': 'Loading…',
'apikeys.error.api': 'Workspace or API is unavailable',
@@ -160,6 +172,7 @@ var TRANSLATIONS = {
'apikeys.action.revoke': 'Revoke key',
'apikeys.action.delete': 'Delete',
'apikeys.creating': 'Creating…',
'apikeys.approval.warning': 'Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.',
// Secrets page
'secrets.title': 'Secrets',
@@ -268,6 +281,27 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Automatic polling is paused.',
'logs.refresh.title': 'Logs refreshed',
'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.title': 'Usage',
@@ -560,6 +594,24 @@ var TRANSLATIONS = {
'wizard.step5.execution': 'Execution settings',
'wizard.step5.exec_title': 'Request execution',
'wizard.step5.exec_subtitle': 'Timeout, retry count and authorization profile',
'wizard.approval.title': 'Human confirmation',
'wizard.approval.subtitle': 'Enable this for actions that must not run without an explicit user decision.',
'wizard.approval.required_label': 'Require confirmation before execution',
'wizard.approval.required_desc': 'The MCP client receives a pending request, and the action runs only after confirmation through a separate approval endpoint.',
'wizard.approval.mode': 'Confirmation mechanism',
'wizard.approval.mode.custom': 'Custom MCP Approval',
'wizard.approval.mode.elicitation': 'MCP Elicitation',
'wizard.approval.custom_title': 'Custom MCP Approval',
'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.show_payload': 'Send call parameters to the confirmation flow',
'wizard.approval.payload_title': 'Confirmation parameters',
'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.step5.security_level_title': 'Operation security',
'wizard.step5.community_security_note': '',
'wizard.step5.live_title': 'Check and publish',
@@ -905,6 +957,12 @@ var TRANSLATIONS = {
'apikeys.title': 'Ключи агентов',
'apikeys.subtitle': 'Эти ключи используются для подключения MCP клиента к MCP серверу и выдаются на конкретного агента.',
'apikeys.new': 'Создать ключ',
'apikeys.new_mcp': 'Создать ключ MCP-клиента',
'apikeys.new_approval': 'Создать ключ подтверждения',
'apikeys.kind.mcp': 'MCP-клиенты',
'apikeys.kind.approval': 'Подтверждения',
'apikeys.kind.mcp_hint': 'Ключи MCP-клиентов используются для подключения к tools/list и tools/call агента.',
'apikeys.kind.approval_hint': 'Ключи подтверждения используются только внешним интерфейсом, где человек подтверждает действие.',
'apikeys.agent.title': 'Выбор агента',
'apikeys.agent.subtitle': 'Выберите AI-агента для которого выпускается ключ.',
'apikeys.agent.label': 'AI-агент',
@@ -928,7 +986,12 @@ var TRANSLATIONS = {
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
'apikeys.scope.approve': 'Подтверждение ожидающего запроса для этого агента.',
'apikeys.scope.deny': 'Отклонение ожидающего запроса для этого агента.',
'apikeys.scope.read_pending': 'Получение списка запросов, ожидающих подтверждения для этого агента.',
'apikeys.modal.title': 'Создать ключ агента',
'apikeys.modal.title_mcp': 'Создать ключ MCP-клиента',
'apikeys.modal.title_approval': 'Создать ключ подтверждения',
'apikeys.modal.name': 'Имя ключа',
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
@@ -943,6 +1006,7 @@ var TRANSLATIONS = {
'apikeys.status.revoked': 'Отозван',
'apikeys.last_used.never': 'Никогда',
'apikeys.empty.none': 'API-ключей пока нет',
'apikeys.empty.approval': 'Ключей подтверждения пока нет. Они нужны только агентам с инструментами, требующими подтверждения человеком.',
'apikeys.empty.search': 'Нет ключей по текущему поиску',
'apikeys.loading': 'Загрузка…',
'apikeys.error.api': 'Воркспейс или API недоступен',
@@ -970,6 +1034,7 @@ var TRANSLATIONS = {
'apikeys.action.revoke': 'Отозвать ключ',
'apikeys.action.delete': 'Удалить',
'apikeys.creating': 'Создание…',
'apikeys.approval.warning': 'Не передавайте этот ключ LLM или MCP-клиенту. Он нужен только внешнему интерфейсу, где человек подтверждает действие.',
// Secrets page
'secrets.title': 'Секреты',
@@ -1080,6 +1145,27 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Автоматический опрос остановлен.',
'logs.refresh.title': 'Логи обновлены',
'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.title': 'Использование',
@@ -1372,6 +1458,24 @@ var TRANSLATIONS = {
'wizard.step5.execution': 'Параметры выполнения',
'wizard.step5.exec_title': 'Выполнение запроса',
'wizard.step5.exec_subtitle': 'Время ожидания, повторные попытки и профиль авторизации',
'wizard.approval.title': 'Подтверждение человеком',
'wizard.approval.subtitle': 'Включайте для действий, которые нельзя выполнять без явного решения пользователя.',
'wizard.approval.required_label': 'Требовать подтверждение перед выполнением',
'wizard.approval.required_desc': 'Инструмент не выполнится сразу. Crank сначала запросит подтверждение выбранным способом.',
'wizard.approval.mode': 'Механизм подтверждения',
'wizard.approval.mode.custom': 'Custom MCP Approval',
'wizard.approval.mode.elicitation': 'MCP Elicitation',
'wizard.approval.custom_title': 'Custom MCP Approval',
'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.show_payload': 'Передавать параметры вызова в подтверждение',
'wizard.approval.payload_title': 'Параметры подтверждения',
'wizard.approval.payload_body': 'Если включено, Crank передаст параметры вызова вместе с запросом подтверждения. Так внешний интерфейс или MCP-клиент сможет показать пользователю, какое действие он подтверждает. Если параметры содержат чувствительные данные, выключите эту опцию.',
'wizard.step5.security_level_title': 'Защита операции',
'wizard.step5.community_security_note': '',
'wizard.step5.live_title': 'Проверка и публикация',
+168 -4
View File
@@ -11,9 +11,14 @@ document.addEventListener('DOMContentLoaded', function () {
workspaceId: null,
loading: false,
loadError: '',
approvals: [],
approvalsLoading: false,
approvalsError: '',
};
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 refreshBtn = document.getElementById('refresh-btn');
var timeRangeSel = document.getElementById('time-range');
@@ -54,6 +59,19 @@ document.addEventListener('DOMContentLoaded', function () {
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) {
var node = document.createElement(tag);
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) {
logList.innerHTML = '';
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) {
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
return;
@@ -343,7 +497,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (!state.liveMode) {
return;
}
state.timer = setInterval(loadLogs, 4000);
state.timer = setInterval(refreshOperationalData, 4000);
}
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) {
timeRangeSel.value = state.period;
timeRangeSel.addEventListener('change', function () {
@@ -405,15 +569,15 @@ document.addEventListener('DOMContentLoaded', function () {
window.addEventListener('crank:workspacechange', function () {
state.details = {};
state.openId = null;
loadLogs();
refreshOperationalData();
});
setLiveState();
startPolling();
if (window.whenWorkspacesReady) {
window.whenWorkspacesReady().finally(loadLogs);
window.whenWorkspacesReady().finally(refreshOperationalData);
} else {
loadLogs();
refreshOperationalData();
}
});
+2
View File
@@ -495,6 +495,7 @@
state.filterMethod = '';
qs('openapi-import-document').value = '';
qs('openapi-import-file').value = '';
qs('openapi-import-file-name').textContent = 'Файл не выбран';
qs('openapi-import-server-custom').value = '';
qs('openapi-import-conflict-mode').value = 'rename';
if (qs('openapi-import-search')) qs('openapi-import-search').value = '';
@@ -539,6 +540,7 @@
qs('openapi-import-file').addEventListener('change', async function(event) {
var file = event.target.files && event.target.files[0];
if (!file) return;
qs('openapi-import-file-name').textContent = file.name;
qs('openapi-import-document').value = await file.text();
});
document.querySelectorAll('[data-openapi-close]').forEach(function(node) {
+87 -1
View File
@@ -27,6 +27,44 @@ function buildWizardState() {
};
}
function checkedValue(id) {
var element = document.getElementById(id);
return !!(element && element.checked);
}
function normalizeApprovalTtlSeconds(value) {
var ttl = Number(value || 300);
if (!Number.isFinite(ttl)) return 300;
return Math.max(1, Math.min(300, Math.round(ttl)));
}
function buildApprovalPolicy() {
if (!checkedValue('approval-required')) return null;
return {
required: true,
mode: textValue('approval-mode') || 'custom',
risk_level: 'normal',
ttl_seconds: normalizeApprovalTtlSeconds(textValue('approval-ttl-seconds')),
show_payload_preview: checkedValue('approval-show-payload-preview'),
payload_preview_mode: 'summary',
elicitation_message: textValue('approval-mode') === 'elicitation'
? (textValue('approval-elicitation-message') || null)
: null,
};
}
function applyApprovalPolicyToExecutionConfig(config) {
var next = config || {};
var policy = buildApprovalPolicy();
if (policy) {
next.approval_policy = policy;
} else {
next.approval_policy = null;
}
return next;
}
function collectWizardPayload() {
var name = textValue('tool-name');
if (!name) throw new Error(tKey('wizard.error.tool_name'));
@@ -50,7 +88,7 @@ function collectWizardPayload() {
output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []),
input_mapping: buildMappingSet(inputMappingValue, 'input'),
output_mapping: buildMappingSet(outputMappingValue, 'output'),
execution_config: parseExecutionConfig(textValue('tool-exec-config')),
execution_config: applyApprovalPolicyToExecutionConfig(parseExecutionConfig(textValue('tool-exec-config'))),
tool_description: buildToolDescription(),
wizard_state: buildWizardState(),
};
@@ -126,6 +164,7 @@ function bindWizardLiveActions() {
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') {
window.CrankWizardMapping.initialize();
}
bindApprovalPolicyControls();
bindAgentFacingPreview();
}
@@ -147,6 +186,52 @@ function bindLiveAction(id, busyLabel, handler) {
});
}
function setApprovalPolicyEditor(policy) {
var enabled = !!(policy && policy.required);
var required = document.getElementById('approval-required');
if (required) required.checked = enabled;
setValue('approval-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');
var showPayload = document.getElementById('approval-show-payload-preview');
if (showPayload) {
showPayload.checked = !policy || policy.show_payload_preview !== false;
}
updateApprovalPolicyUi();
}
function updateApprovalPolicyUi() {
var enabled = checkedValue('approval-required');
var toggle = document.getElementById('approval-required-toggle');
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 (fields) fields.hidden = !enabled;
if (customInfo) customInfo.hidden = mode !== 'custom';
if (elicitationInfo) elicitationInfo.hidden = mode !== 'elicitation';
if (elicitationMessage) elicitationMessage.hidden = mode !== 'elicitation';
}
function bindApprovalPolicyControls() {
[
'approval-required',
'approval-mode',
'approval-elicitation-message',
'approval-ttl-seconds',
'approval-show-payload-preview',
].forEach(function(id) {
var element = document.getElementById(id);
if (!element || element.dataset.approvalBound === 'true') return;
element.dataset.approvalBound = 'true';
element.addEventListener('input', updateApprovalPolicyUi);
element.addEventListener('change', updateApprovalPolicyUi);
});
updateApprovalPolicyUi();
}
async function runWizardLiveAction(button, busyLabel, handler) {
if (!button || button.dataset.busy === 'true') {
return;
@@ -708,4 +793,5 @@ function copyTestResponseToOutputSample() {
bindWizardLiveActions: bindWizardLiveActions,
updateWizardProtocolVisibility: updateWizardProtocolVisibility,
renderAgentFacingPreview: renderAgentFacingPreview,
setApprovalPolicyEditor: setApprovalPolicyEditor,
};
+36 -9
View File
@@ -346,6 +346,25 @@
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) {
var root = field('wizard-request-mapping-rows');
if (!root) return;
@@ -364,7 +383,10 @@
var input = makeInput(row.input, 'form-input input-mono mapping-source', 'base');
input.dataset.role = 'input';
item.appendChild(input);
input.title = 'Поле, которое MCP клиент передает инструменту';
item.appendChild(wrapMappingControl('Из инструмента', input));
item.appendChild(makeMappingArrow());
var select = document.createElement('select');
select.className = 'form-select mapping-target';
@@ -378,16 +400,17 @@
select.appendChild(makeOption(entry[0], entry[1], entry[0] === row.target));
});
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');
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.title = 'Значение по умолчанию, если поле не передано';
item.appendChild(defaultValue);
defaultValue.title = 'Необязательно. Это значение уйдет в API, если агент не передал поле инструмента.';
item.appendChild(wrapMappingControl('Если пусто', defaultValue));
var transform = document.createElement('select');
transform.className = 'form-select mapping-transform';
@@ -398,7 +421,7 @@
});
transform.title = 'Простое преобразование перед отправкой в API';
transform.addEventListener('change', syncVisualMappingsToYaml);
item.appendChild(transform);
item.appendChild(wrapMappingControl('Преобразование', transform));
item.appendChild(makeRemoveButton(item));
return item;
@@ -422,11 +445,15 @@
var responsePath = makeInput(row.responsePath, 'form-input input-mono mapping-response-path', 'rates.EUR');
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');
output.dataset.role = 'output';
item.appendChild(output);
output.title = 'Поле результата, которое получит MCP клиент';
item.appendChild(wrapMappingControl('В результат инструмента', output));
item.appendChild(makeRemoveButton(item));
return item;
}
+8
View File
@@ -429,6 +429,13 @@ function executionConfigToEditorValue(config) {
return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2);
}
function setApprovalPolicyFromSnapshot(config) {
if (!window.CrankWizardLive || typeof window.CrankWizardLive.setApprovalPolicyEditor !== 'function') {
return;
}
window.CrankWizardLive.setApprovalPolicyEditor(config && config.approval_policy ? config.approval_policy : null);
}
function operationSnapshot(versionDocument) {
if (!versionDocument) return {};
return versionDocument.snapshot || versionDocument;
@@ -487,6 +494,7 @@ function prefillWizardFromEdit(detail, versionDocument) {
setValue('tool-input-mapping', mappingSetToEditorValue(snapshot.input_mapping, 'input', snapshot.protocol || detail.protocol));
setValue('tool-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol));
setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {}));
setApprovalPolicyFromSnapshot(snapshot.execution_config || {});
prefillWizardSamples(snapshot);
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
window.CrankWizardMapping.renderFromEditors();
+139 -129
View File
@@ -6,18 +6,18 @@
"": {
"name": "crank-ui",
"dependencies": {
"alpinejs": "3.15.9",
"js-yaml": "4.1.1"
"alpinejs": "3.15.12",
"js-yaml": "5.2.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"esbuild": "^0.28.0"
"@playwright/test": "1.61.1",
"esbuild": "0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -32,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -457,13 +457,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
@@ -488,9 +488,9 @@
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.15.9",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.9.tgz",
"integrity": "sha512-O30m8Tw/aARbLXmeTnISAFgrNm0K71PT7bZy/1NgRqFD36QGb34VJ4a6WBL1iIO/bofN+LkIkKLikUTkfPL2wQ==",
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
@@ -503,9 +503,9 @@
"license": "Python-2.0"
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -516,32 +516,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
@@ -560,25 +560,35 @@
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
"integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
@@ -591,9 +601,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+4 -4
View File
@@ -8,11 +8,11 @@
"e2e:headed": "playwright test --headed"
},
"dependencies": {
"alpinejs": "3.15.9",
"js-yaml": "4.1.1"
"alpinejs": "3.15.12",
"js-yaml": "5.2.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"esbuild": "^0.28.0"
"@playwright/test": "1.61.1",
"esbuild": "0.28.1"
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ const BUNDLES = {
},
wizard: {
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-shell.js',
'js/wizard-upstreams.js',
+18 -1
View File
@@ -22,8 +22,25 @@ test('api keys page opens create key flow', async ({ page }) => {
await expect(page.locator('#btn-create-key')).toBeEnabled();
await page.locator('#btn-create-key').click();
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('.modal-title')).toHaveText(localized('Create agent key', 'Создать ключ агента'));
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
await page.locator('#modal-done-btn').click();
await page.locator('#key-kind-approval').click();
await expect(page.locator('#key-kind-hint')).toContainText(
localized('human confirmation interface', 'человек подтверждает действие')
);
await expect(page.locator('#btn-create-key')).toContainText(
localized('Create approval key', 'Создать ключ подтверждения')
);
await page.locator('#btn-create-key').click();
await expect(page.locator('.modal-title')).toHaveText(localized('Create approval key', 'Создать ключ подтверждения'));
await expect(page.locator('#approval-key-warning')).toContainText(
localized('Do not pass this key to an LLM', 'Не передавайте этот ключ LLM')
);
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
});
+30
View File
@@ -550,6 +550,13 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
ttl_seconds: 180,
show_payload_preview: true,
payload_preview_mode: 'masked_json',
},
},
tool_description: {
title: 'Получить историю курсов за месяц',
@@ -765,6 +772,13 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
ttl_seconds: 180,
show_payload_preview: true,
payload_preview_mode: 'masked_json',
},
},
tool_description: {
title: 'Получить последний курс',
@@ -810,6 +824,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(/path\.date/);
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-config-fields')).toBeVisible();
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-payload-preview-mode')).toHaveCount(0);
await page.locator('.btn-save-draft').click();
await expect.poll(() => updatePayload).not.toBeNull();
@@ -835,6 +856,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
mode: 'custom',
risk_level: 'normal',
ttl_seconds: 180,
show_payload_preview: true,
payload_preview_mode: 'summary',
elicitation_message: null,
},
});
expect(updatePayload.tool_description).toEqual({
title: 'Получить последний курс',
@@ -1,7 +1,7 @@
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::UserSessionId;
use rand::RngCore;
use rand::RngExt;
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime};
@@ -23,7 +23,7 @@ pub enum 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 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 expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::hours(session_ttl_hours))
+52
View File
@@ -27,6 +27,41 @@ pub(super) async fn require_machine_access(
Ok(credential)
}
pub(super) async fn require_approval_access(
state: &Arc<AppState>,
path: &AgentRoutePath,
headers: &HeaderMap,
required_scope: PlatformApiKeyScope,
) -> Result<crank_registry::PlatformApiKeyRecord, StatusCode> {
let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?;
let secret_hash = hash_access_secret(secret);
let Some(api_key) = state
.registry
.get_approval_api_key_by_secret_for_agent_slug(
&path.workspace_slug,
&path.agent_slug,
&secret_hash,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
else {
return Err(StatusCode::UNAUTHORIZED);
};
if !approval_allows_scope(&api_key.api_key.scopes, required_scope) {
return Err(StatusCode::FORBIDDEN);
}
let used_at = OffsetDateTime::now_utc();
state
.registry
.touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(api_key)
}
pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
let (scheme, token) = value.split_once(' ')?;
@@ -130,9 +165,26 @@ fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeySc
PlatformApiKeyScope::Deploy => scopes
.iter()
.any(|scope| matches!(scope, PlatformApiKeyScope::Deploy)),
PlatformApiKeyScope::Approve
| PlatformApiKeyScope::Deny
| PlatformApiKeyScope::ReadPending => false,
}
}
fn approval_allows_scope(
scopes: &[PlatformApiKeyScope],
required_scope: PlatformApiKeyScope,
) -> bool {
scopes.iter().any(|scope| match required_scope {
PlatformApiKeyScope::Approve => matches!(scope, PlatformApiKeyScope::Approve),
PlatformApiKeyScope::Deny => matches!(scope, PlatformApiKeyScope::Deny),
PlatformApiKeyScope::ReadPending => matches!(scope, PlatformApiKeyScope::ReadPending),
PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy => {
false
}
})
}
fn security_level_rank(level: OperationSecurityLevel) -> u8 {
match level {
OperationSecurityLevel::Standard => 0,
+593 -23
View File
@@ -10,13 +10,18 @@ use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response, sse::Event},
routing::get,
routing::{get, post},
};
use crank_core::{
AuthProfile, CoordinationStateStore, InvocationLevel, InvocationLog, InvocationLogId,
InvocationSource, InvocationStatus, PlatformApiKeyScope, SecretId,
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus,
OperationApprovalMode, PlatformApiKeyScope, SecretId,
};
use crank_registry::{
ApprovalRequestRecord, CreateApprovalRequest, CreateInvocationLogRequest,
DecideApprovalRequest, ExpireApprovalRequest, FinishApprovalRequest, PostgresRegistry,
PublishedAgentTool,
};
use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool};
use crank_runtime::{
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
RuntimeOperation, RuntimeRequestContext, SecretCrypto,
@@ -29,8 +34,8 @@ use tracing::info;
use crate::{
access::{
credential_allows_security_level, require_machine_access, serialize_machine_access_mode,
serialize_security_level,
credential_allows_security_level, require_approval_access, require_machine_access,
serialize_machine_access_mode, serialize_security_level,
},
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
catalog::PublishedToolCatalog,
@@ -74,6 +79,8 @@ pub(super) struct AppState {
struct InitializeParams {
#[serde(rename = "protocolVersion")]
protocol_version: String,
#[serde(default)]
capabilities: Value,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -83,6 +90,13 @@ struct ToolCallParams {
arguments: Value,
}
#[derive(Debug, Deserialize)]
struct ApprovalDecisionPayload {
approve: String,
#[serde(default)]
note: Option<String>,
}
#[derive(Clone)]
struct ResolvedToolCall {
tool: PublishedAgentTool,
@@ -100,6 +114,13 @@ pub(super) struct AgentRoutePath {
pub(super) agent_slug: String,
}
#[derive(Clone, Debug, Deserialize)]
struct ApprovalRoutePath {
workspace_slug: String,
agent_slug: String,
approval_id: String,
}
#[allow(clippy::too_many_arguments)]
pub fn build_app(
registry: PostgresRegistry,
@@ -129,6 +150,22 @@ pub fn build_app(
"/v1/{workspace_slug}/{agent_slug}",
get(mcp_get).post(mcp_post).delete(mcp_delete),
)
.route(
"/v1/{workspace_slug}/{agent_slug}/approvals",
get(list_pending_approvals),
)
.route(
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve",
post(approve_request),
)
.route(
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}",
get(get_approval_request),
)
.route(
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny",
post(deny_request),
)
.with_state(state)
}
@@ -139,6 +176,335 @@ async fn health() -> Json<Value> {
}))
}
async fn list_pending_approvals(
Path(path): Path<AgentRoutePath>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
let key =
match require_approval_access(&state, &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();
};
match state
.registry
.list_pending_approval_requests_for_agent(&key.api_key.workspace_id, agent_id)
.await
{
Ok(items) => Json(json!({ "items": items })).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn approve_request(
Path(path): Path<ApprovalRoutePath>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Response {
decide_approval_request(
path,
state,
headers,
payload,
PlatformApiKeyScope::Approve,
ApprovalRequestStatus::Approved,
)
.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(
Path(path): Path<ApprovalRoutePath>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Response {
decide_approval_request(
path,
state,
headers,
payload,
PlatformApiKeyScope::Deny,
ApprovalRequestStatus::Denied,
)
.await
}
async fn decide_approval_request(
path: ApprovalRoutePath,
state: Arc<AppState>,
headers: HeaderMap,
payload: ApprovalDecisionPayload,
required_scope: PlatformApiKeyScope,
status: ApprovalRequestStatus,
) -> 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, required_scope).await {
Ok(key) => key,
Err(status) => return status.into_response(),
};
if (status == ApprovalRequestStatus::Approved && !payload.approve.eq_ignore_ascii_case("yes"))
|| (status == ApprovalRequestStatus::Denied && !payload.approve.eq_ignore_ascii_case("no"))
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_decision_payload",
"message": "approve must be yes for approve endpoint and no for deny endpoint"
})),
)
.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);
match state
.registry
.decide_approval_request(DecideApprovalRequest {
workspace_id: &key.api_key.workspace_id,
agent_id,
approval_id: &approval_id,
status,
decided_at: OffsetDateTime::now_utc(),
decided_by_key_id: &key.api_key.id,
response_payload: Some(json!({ "approve": payload.approve })),
decision_note: payload.note.as_deref(),
})
.await
{
Ok(Some(record)) if status == ApprovalRequestStatus::Approved => {
match execute_approved_request(&state, &agent_path, record).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(None) => StatusCode::CONFLICT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
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 Err(StatusCode::NOT_FOUND.into_response());
};
let operation = runtime_operation(&tool);
let request_preview = build_request_preview(
&state.runtime,
&operation,
&approval.approval.request_payload,
);
let started_at = Instant::now();
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()),
)
.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)),
),
};
let _ = 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;
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 mcp_get(
Path(path): Path<AgentRoutePath>,
State(state): State<Arc<AppState>>,
@@ -275,24 +641,23 @@ async fn mcp_post(
);
}
if let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID) {
if let Ok(session_id) = session_id.to_str() {
let session = match state.sessions.get(session_id).await {
Ok(session) => session,
Err(_) => {
return with_request_id_header(
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_id) = headers.get(HEADER_MCP_SESSION_ID)
&& let Ok(session_id) = session_id.to_str()
{
let session = match state.sessions.get(session_id).await {
Ok(session) => session,
Err(_) => {
return with_request_id_header(
StatusCode::INTERNAL_SERVER_ERROR.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);
}
}
@@ -591,6 +956,20 @@ async fn handle_base_tool_call(
let tool = execution.tool;
let arguments = execution.arguments;
let operation = runtime_operation(&tool);
if let Some(response) = maybe_handle_approval_policy(
&state,
session,
message,
response_mode,
&tool,
&arguments,
transport_request_id,
)
.await
{
return response;
}
let mut runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id)
.with_response_cache_scope(
tool.workspace_id.as_str().to_owned(),
@@ -674,6 +1053,192 @@ async fn handle_base_tool_call(
}
}
async fn maybe_handle_approval_policy(
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()?;
if !policy.required {
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 now = OffsetDateTime::now_utc();
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
let approval_url = approval_url_for(tool, &approval_id);
let response_payload = json!({
"status": "approval_required",
"approval_id": approval_id.as_str(),
"approval_url": approval_url,
"approve": {
"method": "POST",
"url": format!("{approval_url}/approve"),
"body": { "approve": "yes" }
},
"deny": {
"method": "POST",
"url": format!("{approval_url}/deny"),
"body": { "approve": "no" }
},
"expires_at": expires_at,
"risk_level": policy.risk_level,
"payload_preview": if policy.show_payload_preview {
arguments.clone()
} else {
Value::Null
},
});
let approval = ApprovalRequest {
id: approval_id,
workspace_id: tool.workspace_id.clone(),
agent_id: tool.agent_id.clone(),
operation_id: tool.operation.id.clone(),
operation_version: tool.operation.version,
status: ApprovalRequestStatus::Pending,
risk_level: policy.risk_level,
request_payload: arguments.clone(),
response_payload: None,
created_at: now,
expires_at,
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
if let Err(error) = state
.registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
{
return Some(internal_jsonrpc_error(message, error));
}
let _ = persist_invocation(
state,
tool,
InvocationRecord {
request_id: Some(transport_request_id),
tool_name: &tool.tool_name,
status: InvocationStatus::Ok,
level: InvocationLevel::Info,
message: "agent tool call is waiting for human approval",
status_code: None,
error_kind: None,
duration: Duration::from_millis(0),
request_preview: arguments.clone(),
response_preview: response_payload.clone(),
},
)
.await;
Some(success_tool_response(
message,
response_mode,
&session.protocol_version,
response_payload,
))
}
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 {
format!(
"/v1/{}/{}/approvals/{}",
tool.workspace_slug,
tool.agent_slug,
approval_id.as_str()
)
}
async fn handle_initialize(
state: Arc<AppState>,
path: &AgentRoutePath,
@@ -711,12 +1276,17 @@ async fn handle_initialize(
};
let now = OffsetDateTime::now_utc();
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
.sessions
.create(
protocol_version,
&path.workspace_slug,
&path.agent_slug,
supports_elicitation,
now,
Some(expires_at),
)
+32 -15
View File
@@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use crank_registry::PostgresPoolConfig;
use sqlx::{
PgPool,
PgPool, Row,
postgres::{PgConnectOptions, PgPoolOptions},
query,
};
@@ -17,6 +17,7 @@ pub struct SessionState {
pub id: String,
pub protocol_version: String,
pub initialized: bool,
pub supports_elicitation: bool,
pub workspace_slug: String,
pub agent_slug: String,
pub created_at: OffsetDateTime,
@@ -37,6 +38,7 @@ pub trait TransportSessionStore: Send + Sync {
protocol_version: &str,
workspace_slug: &str,
agent_slug: &str,
supports_elicitation: bool,
now: OffsetDateTime,
expires_at: Option<OffsetDateTime>,
) -> Result<String, SessionStoreError>;
@@ -100,6 +102,7 @@ impl TransportSessionStore for InMemorySessionStore {
protocol_version: &str,
workspace_slug: &str,
agent_slug: &str,
supports_elicitation: bool,
now: OffsetDateTime,
expires_at: Option<OffsetDateTime>,
) -> Result<String, SessionStoreError> {
@@ -112,6 +115,7 @@ impl TransportSessionStore for InMemorySessionStore {
id: session_id.clone(),
protocol_version: protocol_version.to_owned(),
initialized: false,
supports_elicitation,
workspace_slug: workspace_slug.to_owned(),
agent_slug: agent_slug.to_owned(),
created_at: now,
@@ -169,6 +173,7 @@ impl TransportSessionStore for PostgresTransportSessionStore {
protocol_version: &str,
workspace_slug: &str,
agent_slug: &str,
supports_elicitation: bool,
now: OffsetDateTime,
expires_at: Option<OffsetDateTime>,
) -> Result<String, SessionStoreError> {
@@ -178,17 +183,19 @@ impl TransportSessionStore for PostgresTransportSessionStore {
id,
protocol_version,
initialized,
supports_elicitation,
workspace_slug,
agent_slug,
created_at,
updated_at,
expires_at
) 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(protocol_version)
.bind(supports_elicitation)
.bind(workspace_slug)
.bind(agent_slug)
.bind(now)
@@ -203,20 +210,21 @@ impl TransportSessionStore for PostgresTransportSessionStore {
}
async fn get(&self, session_id: &str) -> Result<Option<SessionState>, SessionStoreError> {
let row = sqlx::query!(
let row = sqlx::query(
"select
id,
protocol_version,
initialized,
supports_elicitation,
workspace_slug,
agent_slug,
created_at as \"created_at!: OffsetDateTime\",
updated_at as \"updated_at!: OffsetDateTime\",
expires_at as \"expires_at: OffsetDateTime\"
created_at,
updated_at,
expires_at
from mcp_transport_sessions
where id = $1",
session_id,
)
.bind(session_id)
.fetch_optional(&self.pool)
.await
.map_err(|error| SessionStoreError {
@@ -224,14 +232,15 @@ impl TransportSessionStore for PostgresTransportSessionStore {
})?;
let Some(session) = row.map(|row| SessionState {
id: row.id,
protocol_version: row.protocol_version,
initialized: row.initialized,
workspace_slug: row.workspace_slug,
agent_slug: row.agent_slug,
created_at: row.created_at,
updated_at: row.updated_at,
expires_at: row.expires_at,
id: row.get("id"),
protocol_version: row.get("protocol_version"),
initialized: row.get("initialized"),
supports_elicitation: row.get("supports_elicitation"),
workspace_slug: row.get("workspace_slug"),
agent_slug: row.get("agent_slug"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
expires_at: row.get("expires_at"),
}) else {
return Ok(None);
};
@@ -291,6 +300,7 @@ async fn apply_postgres_migrations(pool: &PgPool) -> Result<(), SessionStoreErro
id text primary key,
protocol_version text not null,
initialized boolean not null default false,
supports_elicitation boolean not null default false,
workspace_slug text not null,
agent_slug text not null,
created_at timestamptz not null,
@@ -304,6 +314,13 @@ async fn apply_postgres_migrations(pool: &PgPool) -> Result<(), SessionStoreErro
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(
"alter table mcp_transport_sessions add column if not exists expires_at timestamptz null",
)
@@ -32,6 +32,7 @@ async fn postgres_transport_sessions_survive_store_reconnect() {
"2025-11-25",
"default",
"sales",
false,
created_at,
Some(created_at + time::Duration::days(30)),
)
@@ -73,6 +74,7 @@ async fn postgres_transport_sessions_evict_expired_rows_on_read() {
"2025-11-25",
"default",
"sales",
false,
timestamp("2026-05-01T10:00:00Z"),
Some(timestamp("2026-05-01T10:00:01Z")),
)
@@ -106,6 +106,7 @@ fn operation() -> RegistryOperation {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -13,7 +13,7 @@ async fn creates_and_reads_transport_sessions() {
let created_at = timestamp("2026-05-01T10:00:00Z");
let session_id = store
.create("2025-11-25", "default", "sales", created_at, None)
.create("2025-11-25", "default", "sales", false, created_at, None)
.await
.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.agent_slug, "sales");
assert!(!session.initialized);
assert!(!session.supports_elicitation);
assert_eq!(session.created_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 session_id = store
.create("2025-11-25", "default", "sales", created_at, None)
.create("2025-11-25", "default", "sales", false, created_at, None)
.await
.unwrap();
@@ -61,6 +62,7 @@ async fn drops_expired_in_memory_transport_sessions_on_read() {
"2025-11-25",
"default",
"sales",
false,
created_at,
Some(expires_at),
)
+28 -1
View File
@@ -35,12 +35,22 @@ pub enum PlatformApiKeyStatus {
Revoked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlatformApiKeyKind {
McpClient,
Approval,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlatformApiKeyScope {
Read,
Write,
Deploy,
Approve,
Deny,
ReadPending,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -82,6 +92,8 @@ pub struct PlatformApiKey {
pub workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<AgentId>,
#[serde(default = "default_platform_api_key_kind")]
pub key_kind: PlatformApiKeyKind,
pub name: String,
pub prefix: String,
pub scopes: Vec<PlatformApiKeyScope>,
@@ -90,6 +102,14 @@ pub struct PlatformApiKey {
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub last_used_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
pub expires_at: Option<OffsetDateTime>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_origins: Vec<String>,
}
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
PlatformApiKeyKind::McpClient
}
#[cfg(test)]
@@ -97,7 +117,10 @@ mod tests {
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
use super::{
PlatformApiKey, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User,
UserStatus,
};
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
#[test]
@@ -138,16 +161,20 @@ mod tests {
id: PlatformApiKeyId::new("pk_01"),
workspace_id: WorkspaceId::new("ws_01"),
agent_id: Some(AgentId::new("agent_01")),
key_kind: PlatformApiKeyKind::McpClient,
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::parse("2026-03-25T12:01:00Z", &Rfc3339).unwrap(),
last_used_at: Some(OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap()),
expires_at: None,
allowed_origins: Vec::new(),
};
let value = serde_json::to_value(&api_key).unwrap();
assert_eq!(value["key_kind"], json!("mcp_client"));
assert_eq!(value["created_at"], json!("2026-03-25T12:01:00Z"));
assert_eq!(value["last_used_at"], json!("2026-03-25T12:05:00Z"));
}
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;
use crate::ids::{AgentId, ApprovalRequestId, OperationId, PlatformApiKeyId, WorkspaceId};
use crate::operation::OperationApprovalRiskLevel;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalRequestStatus {
#[default]
Pending,
Approved,
Denied,
Expired,
Completed,
Failed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ApprovalRequest {
pub id: ApprovalRequestId,
pub workspace_id: WorkspaceId,
pub agent_id: AgentId,
pub operation_id: OperationId,
pub operation_version: u32,
pub status: ApprovalRequestStatus,
pub risk_level: OperationApprovalRiskLevel,
pub request_payload: Value,
pub response_payload: Option<Value>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub decided_at: Option<OffsetDateTime>,
pub decided_by_key_id: Option<PlatformApiKeyId>,
pub decision_note: Option<String>,
}
+2 -7
View File
@@ -14,18 +14,13 @@ pub enum MachineAccessMode {
StaticAgentKey,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OperationSecurityLevel {
#[default]
Standard,
}
impl Default for OperationSecurityLevel {
fn default() -> Self {
Self::Standard
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct EditionLimits {
pub max_workspaces: Option<u32>,
+1
View File
@@ -53,6 +53,7 @@ define_id!(UserSessionId);
define_id!(AgentId);
define_id!(InvitationId);
define_id!(PlatformApiKeyId);
define_id!(ApprovalRequestId);
define_id!(InvocationLogId);
define_id!(AuditEventId);
define_id!(SecretId);
+19 -13
View File
@@ -1,5 +1,6 @@
pub mod access;
pub mod agent;
pub mod approval;
pub mod auth;
pub mod cache;
pub mod edition;
@@ -15,9 +16,10 @@ pub mod workspace;
pub mod domain {
pub use crate::access::{
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
};
pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
pub use crate::approval::{ApprovalRequest, ApprovalRequestStatus};
pub use crate::auth::{
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
BearerAuthConfig,
@@ -31,9 +33,9 @@ pub mod domain {
ProductEdition,
};
pub use crate::ids::{
AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId,
OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId,
WorkspaceId,
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
UserSessionId, WorkspaceId,
};
pub use crate::observability::{
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
@@ -41,9 +43,10 @@ pub mod domain {
};
pub use crate::operation::{
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy,
OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target,
ToolDescription, ToolExample, WizardState,
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy,
RestTarget, RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
};
pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion};
@@ -85,9 +88,10 @@ pub mod ports {
pub use access::{
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
};
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
pub use approval::{ApprovalRequest, ApprovalRequestStatus};
pub use auth::{
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
BearerAuthConfig,
@@ -120,17 +124,19 @@ pub use ext::protocol::{
SharedProtocolAdapter,
};
pub use ids::{
AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId,
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
UserSessionId, WorkspaceId,
};
pub use observability::{
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
};
pub use operation::{
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy,
OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target,
ToolDescription, ToolExample, WizardState,
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget,
RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
};
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
+42
View File
@@ -103,6 +103,45 @@ pub struct OperationSafetyPolicy {
pub confirmation: Option<ConfirmationPolicy>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationApprovalRiskLevel {
#[default]
Normal,
Dangerous,
Financial,
Irreversible,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationApprovalPayloadPreviewMode {
#[default]
Summary,
MaskedJson,
}
#[derive(Clone, 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)]
pub struct OperationApprovalPolicy {
pub required: bool,
#[serde(default)]
pub mode: OperationApprovalMode,
pub risk_level: OperationApprovalRiskLevel,
pub ttl_seconds: u32,
pub show_payload_preview: bool,
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)]
pub struct ExecutionConfig {
pub timeout_ms: u64,
@@ -115,6 +154,8 @@ pub struct ExecutionConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub safety: Option<OperationSafetyPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<OperationApprovalPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_profile_ref: Option<AuthProfileId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
@@ -411,6 +452,7 @@ updated_at: 2026-03-25T08:10:00Z
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
headers: BTreeMap::new(),
},
+1
View File
@@ -231,6 +231,7 @@ fn default_execution_config() -> ExecutionConfig {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
}
+4 -4
View File
@@ -193,10 +193,10 @@ fn text(value: &Value, key: &str) -> Option<String> {
fn collapse_composition(value: &Value) -> Value {
for key in ["allOf", "oneOf", "anyOf"] {
if let Some(items) = value.get(key).and_then(Value::as_array) {
if let Some(first) = items.first() {
return first.clone();
}
if let Some(items) = value.get(key).and_then(Value::as_array)
&& let Some(first) = items.first()
{
return first.clone();
}
}
value.clone()
+32 -28
View File
@@ -9,12 +9,12 @@ pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations}
pub mod records {
pub use crate::model::{
AgentSummary, AgentVersionRecord, AuthUserRecord, DescriptorKind, DescriptorMetadata,
ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord,
InvocationLogRecord, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord, DescriptorKind,
DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
Page, PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind,
SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
@@ -23,13 +23,15 @@ pub mod records {
pub mod requests {
pub use crate::model::{
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateImportJobRequest,
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, FinishImportJobRequest, ListInvocationLogsQuery,
PublishAgentRequest, PublishRequest, RotateSecretRequest, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
UpdateWorkspaceRequest, UsageQuery,
};
}
@@ -39,20 +41,22 @@ pub mod infrastructure {
}
pub use model::{
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind,
ImportJobStatus, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest,
PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportJob, ImportJobId,
ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord,
ListApprovalRequestsQuery, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
+51 -1
View File
@@ -148,11 +148,14 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
name text not null,
prefix text not null,
secret_hash text not null,
key_kind text not null default 'mcp_client',
scopes_json jsonb not null,
status text not null,
created_at timestamptz not null,
last_used_at timestamptz null,
revoked_at timestamptz null
revoked_at timestamptz null,
expires_at timestamptz null,
allowed_origins_json jsonb not null default '[]'::jsonb
)",
)
.execute(pool)
@@ -166,6 +169,19 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
query("alter table platform_api_keys add column if not exists agent_id text null")
.execute(pool)
.await?;
query(
"alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'",
)
.execute(pool)
.await?;
query("alter table platform_api_keys add column if not exists expires_at timestamptz null")
.execute(pool)
.await?;
query(
"alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb",
)
.execute(pool)
.await?;
query(
"insert into workspaces (
@@ -543,6 +559,40 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
.execute(pool)
.await?;
query(
"create table if not exists approval_requests (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text not null references agents(id) on delete cascade,
operation_id text not null references operations(id) on delete cascade,
operation_version integer not null,
status text not null,
risk_level text not null,
request_payload_json jsonb not null,
response_payload_json jsonb null,
created_at timestamptz not null,
expires_at timestamptz not null,
decided_at timestamptz null,
decided_by_key_id text null references platform_api_keys(id) on delete set null,
decision_note text null
)",
)
.execute(pool)
.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(
"create index if not exists approval_requests_agent_status_idx
on approval_requests(workspace_id, agent_id, status, expires_at)",
)
.execute(pool)
.await?;
query(
"create table if not exists invocation_logs (
id text primary key,
+53 -5
View File
@@ -1,9 +1,10 @@
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
Operation, OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, Protocol,
SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId,
Workspace, WorkspaceId,
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
Protocol, SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User,
UserSessionId, Workspace, WorkspaceId,
};
use crank_mapping::MappingSet;
use crank_schema::Schema;
@@ -95,6 +96,11 @@ pub struct PlatformApiKeyRecord {
pub api_key: PlatformApiKey,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ApprovalRequestRecord {
pub approval: ApprovalRequest,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecretRecord {
pub secret: Secret,
@@ -513,6 +519,48 @@ pub struct CreatePlatformApiKeyRequest<'a> {
pub secret_hash: &'a str,
}
#[derive(Clone, Debug)]
pub struct CreateApprovalRequest<'a> {
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)]
pub struct DecideApprovalRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: &'a AgentId,
pub approval_id: &'a ApprovalRequestId,
pub status: ApprovalRequestStatus,
pub decided_at: OffsetDateTime,
pub decided_by_key_id: &'a PlatformApiKeyId,
pub response_payload: Option<Value>,
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)]
pub struct CreateSecretRequest<'a> {
pub secret: &'a Secret,
+103 -5
View File
@@ -11,12 +11,15 @@ impl PostgresRegistry {
id,
workspace_id,
agent_id,
key_kind,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
last_used_at,
expires_at,
allowed_origins_json
from platform_api_keys
where workspace_id = $1
and agent_id = $2
@@ -34,12 +37,20 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(
&row.get::<String, _>("key_kind"),
"key_kind",
)?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -55,12 +66,15 @@ impl PostgresRegistry {
id,
workspace_id,
agent_id,
key_kind,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
last_used_at,
expires_at,
allowed_origins_json
from platform_api_keys
where workspace_id = $1
order by created_at desc",
@@ -76,12 +90,20 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(
&row.get::<String, _>("key_kind"),
"key_kind",
)?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -99,19 +121,24 @@ impl PostgresRegistry {
k.id,
k.workspace_id,
k.agent_id,
k.key_kind,
k.name,
k.prefix,
k.scopes_json,
k.status,
k.created_at,
k.last_used_at
k.last_used_at,
k.expires_at,
k.allowed_origins_json
from platform_api_keys k
join workspaces w on w.id = k.workspace_id
join agents a on a.id = k.agent_id
where w.slug = $1
and a.slug = $2
and k.secret_hash = $3
and k.key_kind = 'mcp_client'
and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
limit 1",
)
.bind(workspace_slug)
@@ -126,12 +153,77 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(&row.get::<String, _>("key_kind"), "key_kind")?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
.transpose()
}
pub async fn get_approval_api_key_by_secret_for_agent_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
secret_hash: &str,
) -> Result<Option<PlatformApiKeyRecord>, RegistryError> {
let row = sqlx::query(
"select
k.id,
k.workspace_id,
k.agent_id,
k.key_kind,
k.name,
k.prefix,
k.scopes_json,
k.status,
k.created_at,
k.last_used_at,
k.expires_at,
k.allowed_origins_json
from platform_api_keys k
join workspaces w on w.id = k.workspace_id
join agents a on a.id = k.agent_id
where w.slug = $1
and a.slug = $2
and k.secret_hash = $3
and k.key_kind = 'approval'
and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
limit 1",
)
.bind(workspace_slug)
.bind(agent_slug)
.bind(secret_hash)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(&row.get::<String, _>("key_kind"), "key_kind")?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -150,13 +242,16 @@ impl PostgresRegistry {
name,
prefix,
secret_hash,
key_kind,
scopes_json,
status,
created_at,
last_used_at,
revoked_at
revoked_at,
expires_at,
allowed_origins_json
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11::timestamptz, $12::timestamptz, $13::timestamptz, $14
)",
)
.bind(request.api_key.id.as_str())
@@ -165,11 +260,14 @@ impl PostgresRegistry {
.bind(&request.api_key.name)
.bind(&request.api_key.prefix)
.bind(request.secret_hash)
.bind(serialize_enum_text(&request.api_key.key_kind, "key_kind")?)
.bind(Json(serialize_json_value(&request.api_key.scopes)?))
.bind(serialize_enum_text(&request.api_key.status, "status")?)
.bind(request.api_key.created_at)
.bind(request.api_key.last_used_at)
.bind(Option::<&str>::None)
.bind(request.api_key.expires_at)
.bind(Json(serialize_json_value(&request.api_key.allowed_origins)?))
.execute(&self.pool)
.await;
@@ -0,0 +1,358 @@
use super::*;
impl PostgresRegistry {
pub async fn create_approval_request(
&self,
request: CreateApprovalRequest<'_>,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into approval_requests (
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
) values (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10::timestamptz, $11::timestamptz, $12::timestamptz,
$13, $14
)",
)
.bind(request.approval.id.as_str())
.bind(request.approval.workspace_id.as_str())
.bind(request.approval.agent_id.as_str())
.bind(request.approval.operation_id.as_str())
.bind(to_db_version(request.approval.operation_version))
.bind(serialize_enum_text(
&request.approval.status,
"approval_status",
)?)
.bind(serialize_enum_text(
&request.approval.risk_level,
"approval_risk_level",
)?)
.bind(Json(&request.approval.request_payload))
.bind(request.approval.response_payload.as_ref().map(Json))
.bind(request.approval.created_at)
.bind(request.approval.expires_at)
.bind(request.approval.decided_at)
.bind(
request
.approval
.decided_by_key_id
.as_ref()
.map(PlatformApiKeyId::as_str),
)
.bind(request.approval.decision_note.as_deref())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_pending_approval_requests_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Vec<ApprovalRequestRecord>, RegistryError> {
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 agent_id = $2
and status = 'pending'
and expires_at > now()
order by created_at asc",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(map_approval_request_row).collect()
}
pub async fn get_approval_request_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
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 agent_id = $2
and id = $3
limit 1",
)
.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 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(
&self,
request: DecideApprovalRequest<'_>,
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
let row = sqlx::query(
"update approval_requests
set status = $1,
response_payload_json = $2,
decided_at = $3::timestamptz,
decided_by_key_id = $4,
decision_note = $5
where workspace_id = $6
and agent_id = $7
and id = $8
and status = 'pending'
and expires_at > $3::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(serialize_enum_text(&request.status, "approval_status")?)
.bind(request.response_payload.as_ref().map(Json))
.bind(request.decided_at)
.bind(request.decided_by_key_id.as_str())
.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 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 = '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(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 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 map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, RegistryError> {
Ok(ApprovalRequestRecord {
approval: ApprovalRequest {
id: ApprovalRequestId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: AgentId::new(row.get::<String, _>("agent_id")),
operation_id: OperationId::new(row.get::<String, _>("operation_id")),
operation_version: from_db_version(row.get("operation_version"), "operation_version")?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "approval_status")?,
risk_level: deserialize_enum_text(
&row.get::<String, _>("risk_level"),
"approval_risk_level",
)?,
request_payload: row.get::<Value, _>("request_payload_json"),
response_payload: row.get::<Option<Value>, _>("response_payload_json"),
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
decided_at: row.get("decided_at"),
decided_by_key_id: row
.get::<Option<String>, _>("decided_by_key_id")
.map(PlatformApiKeyId::new),
decision_note: row.get("decision_note"),
},
})
}
@@ -1,7 +1,7 @@
use std::time::Duration;
use sqlx::{
PgPool,
AssertSqlSafe, PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
@@ -58,7 +58,7 @@ impl PostgresRegistry {
.await?;
if let Some(schema) = schema {
sqlx::query(&format!("set search_path to {schema}"))
sqlx::query(AssertSqlSafe(format!("set search_path to {schema}")))
.execute(&pool)
.await?;
}
+17 -13
View File
@@ -1,5 +1,6 @@
mod agent;
mod api_key;
mod approval;
mod auth;
mod connection;
mod import_job;
@@ -13,10 +14,11 @@ mod workspace;
mod yaml_import;
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, HttpMethod,
InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, SecretId,
SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId,
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId,
AuthProfile, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId,
MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
PlatformApiKeyStatus, Secret, SecretId, SecretVersion, Target, UsageRollup, User, UserId,
UserSessionId, Workspace, WorkspaceId,
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
@@ -28,15 +30,17 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError};
use crate::{
error::RegistryError,
model::{
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord,
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListApprovalRequestsQuery,
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation,
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest,
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
@@ -239,7 +239,7 @@ impl PostgresRegistry {
group by 1
order by 1 asc"
);
let rows = sqlx::query(&sql)
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(
@@ -245,7 +245,9 @@ impl TestDatabase {
let schema = crank_test_support::unique_schema_name("test_registry");
admin_pool
.execute(sqlx::query(&format!("create schema {schema}")))
.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
"create schema {schema}"
))))
.await
.unwrap();
@@ -267,10 +269,10 @@ impl TestDatabase {
pub(super) async fn cleanup(&self) {
self.admin_pool
.execute(sqlx::query(&format!(
.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
"drop schema if exists {} cascade",
self.schema
)))
))))
.await
.unwrap();
}
@@ -5,10 +5,11 @@ use super::common::*;
use std::collections::BTreeMap;
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, AuthConfig,
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig,
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthConfig, AuthKind, AuthProfile,
ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod,
InvocationLog, MembershipRole, OperationApprovalRiskLevel, OperationId, OperationSecurityLevel,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target,
ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
};
@@ -19,13 +20,14 @@ use sqlx::{Executor, PgPool, postgres::PgPoolOptions};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crank_registry::{
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId,
YamlImportJobStatus,
CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
FinishApprovalRequest, ListApprovalRequestsQuery, OperationSampleMetadata,
PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError,
RegistryOperation, SampleKind, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion,
YamlImportJobId, YamlImportJobStatus,
};
fn test_workspace_id() -> WorkspaceId {
@@ -372,12 +374,15 @@ async fn manages_platform_api_key_read_paths() {
id: PlatformApiKeyId::new("key_01"),
workspace_id: workspace.id.clone(),
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::McpClient,
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: timestamp("2026-03-25T12:01:00Z"),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
let secret_hash = "secret_hash_01";
@@ -451,3 +456,175 @@ async fn manages_platform_api_key_read_paths() {
database.cleanup().await;
}
#[tokio::test]
async fn manages_approval_request_lifecycle() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = test_workspace_id();
let operation = test_operation("op_approval_01", 1, OperationStatus::Draft);
let agent = test_agent("agent_approval_01", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let approval_key = PlatformApiKey {
id: PlatformApiKeyId::new("key_approval_01"),
workspace_id: workspace_id.clone(),
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::Approval,
name: "Approval key".to_owned(),
prefix: "crk_appr".to_owned(),
scopes: vec![PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny],
status: PlatformApiKeyStatus::Active,
created_at: timestamp("2026-03-25T12:00:00Z"),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
let approval = ApprovalRequest {
id: ApprovalRequestId::new("approval_01"),
workspace_id: workspace_id.clone(),
agent_id: agent.id.clone(),
operation_id: operation.id.clone(),
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_payload: json!({"amount": 100}),
response_payload: None,
created_at: timestamp("2026-03-25T12:01:00Z"),
expires_at: timestamp("2027-03-25T12:06:00Z"),
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
registry
.create_operation(&workspace_id, &operation, None)
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &[],
})
.await
.unwrap();
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &approval_key,
secret_hash: "approval-secret-hash",
})
.await
.unwrap();
registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
.unwrap();
let pending = registry
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
.await
.unwrap();
assert_eq!(pending.len(), 1);
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
.decide_approval_request(DecideApprovalRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
approval_id: &approval.id,
status: ApprovalRequestStatus::Approved,
decided_at: timestamp("2026-03-25T12:02:00Z"),
decided_by_key_id: &approval_key.id,
response_payload: Some(json!({"approve": "yes"})),
decision_note: Some("confirmed in test"),
})
.await
.unwrap()
.unwrap();
assert_eq!(decided.approval.status, ApprovalRequestStatus::Approved);
assert_eq!(
decided.approval.decided_by_key_id,
Some(approval_key.id.clone())
);
assert_eq!(
decided.approval.response_payload,
Some(json!({"approve": "yes"}))
);
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
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
.await
.unwrap();
assert!(pending_after_decision.is_empty());
let repeated_decision = registry
.decide_approval_request(DecideApprovalRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
approval_id: &approval.id,
status: ApprovalRequestStatus::Denied,
decided_at: timestamp("2026-03-25T12:03:00Z"),
decided_by_key_id: &approval_key.id,
response_payload: Some(json!({"approve": "no"})),
decision_note: None,
})
.await
.unwrap();
assert!(repeated_decision.is_none());
database.cleanup().await;
}
+2 -3
View File
@@ -85,10 +85,9 @@ fn key_from_policy(
.header_name
.as_deref()
.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();
@@ -160,6 +160,7 @@ fn destructive_delete_operation() -> Operation<Schema, MappingSet> {
class: OperationSafetyClass::Destructive,
confirmation: Some(ConfirmationPolicy { ttl_ms: 60_000 }),
}),
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -146,6 +146,7 @@ fn idempotent_post_operation() -> Operation<Schema, MappingSet> {
header_name: Some("Idempotency-Key".to_owned()),
}),
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -71,6 +71,7 @@ fn no_input_get_operation() -> Operation<Schema, MappingSet> {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+3 -1
View File
@@ -28,7 +28,9 @@ pub async fn postgres_schema_url(prefix: &str) -> String {
let mut connection = connect_admin_with_retry(database_url).await;
connection
.execute(sqlx::query(&format!("create schema {schema}")))
.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
"create schema {schema}"
))))
.await
.expect("test PostgreSQL schema must be created");
+64
View File
@@ -32,6 +32,8 @@ Authorization: Bearer <agent_api_key>
Ключ выдается в разделе **API ключи**. Полное значение показывается только один раз при создании.
Для операций с подтверждением человеком нужен отдельный ключ подтверждения. Его тоже выдают в разделе **API ключи**, но в режиме **Подтверждения**. Такой ключ нельзя передавать LLM или MCP-клиенту. Он нужен только вашему внешнему интерфейсу, где пользователь нажимает «Подтвердить» или «Отклонить».
## Поддерживаемые методы
MCP methods:
@@ -148,6 +150,68 @@ Crank выполнит REST-запрос:
GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR
```
## Операции с подтверждением человеком
Если в мастере операции включено **Подтверждение человеком**, первый `tools/call` не выполняет REST-запрос сразу. Вместо этого Crank создает ожидающий запрос на подтверждение и возвращает MCP-клиенту структурированный результат:
```json
{
"status": "approval_required",
"approval_id": "approval_...",
"approval_url": "/v1/default/sales/approvals/approval_...",
"approve": {
"method": "POST",
"url": "/v1/default/sales/approvals/approval_.../approve",
"body": { "approve": "yes" }
},
"deny": {
"method": "POST",
"url": "/v1/default/sales/approvals/approval_.../deny",
"body": { "approve": "no" }
}
}
```
`approval_url` в ответе является путем на MCP-сервере. Если вы публикуете MCP через префикс `/mcp`, внешний URL будет начинаться с `/mcp/v1/...`.
Внешний интерфейс подтверждения работает отдельным ключом подтверждения:
```bash
curl https://crank.example.com/mcp/v1/default/sales/approvals \
-H 'Authorization: Bearer <approval_api_key>'
```
Статус конкретного запроса:
```bash
curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id> \
-H 'Authorization: Bearer <approval_api_key>'
```
Подтверждение:
```bash
curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id>/approve \
-H 'Authorization: Bearer <approval_api_key>' \
-H 'Content-Type: application/json' \
--data '{ "approve": "yes", "note": "Пользователь подтвердил действие" }'
```
После подтверждения Crank выполняет исходный REST-запрос с тем payload, который был сохранен при первом `tools/call`. Если запрос прошел успешно, заявка получает статус `completed`, а результат сохраняется в `response_payload`. Если upstream вернул ошибку, заявка получает статус `failed`, а в `response_payload` сохраняется код и текст ошибки.
Отклонение:
```bash
curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id>/deny \
-H 'Authorization: Bearer <approval_api_key>' \
-H 'Content-Type: application/json' \
--data '{ "approve": "no", "note": "Пользователь отклонил действие" }'
```
Ключ MCP-клиента не подходит для этих endpoints. Ключ подтверждения, наоборот, не подходит для `initialize`, `tools/list` и `tools/call`.
Текущая реализация возвращает `approval_required` сразу и не держит исходный `tools/call` открытым до решения пользователя. Поэтому внешний интерфейс может восстановить результат через `GET /approvals/<approval_id>`. Долгое ожидание через Streamable HTTP/SSE запланировано отдельно.
## Как формируется каталог инструментов
MCP-клиент видит только опубликованные операции, которые привязаны к опубликованному агенту.
+1 -1
View File
@@ -60,4 +60,4 @@ YAML-маппинг остается доступен в блоке **Допол
В Community импортируются только REST/HTTP методы: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`.
GraphQL, gRPC, WSDL, SOAP, WebSocket и streaming-протоколы не импортируются в Community.
Другие форматы спецификаций и протоколов в Community-импорт не входят.
+5 -1
View File
@@ -60,12 +60,16 @@
## API ключи
API-ключ выдается на конкретного агента. Ключ позволяет MCP-клиенту:
API-ключ выдается на конкретного агента. В Community есть два режима ключей.
Ключ MCP-клиента позволяет:
- открыть MCP-сессию;
- получить список инструментов агента;
- вызвать опубликованный инструмент.
Ключ подтверждения используется только внешним интерфейсом, где человек подтверждает или отклоняет опасное действие. Такой ключ нельзя передавать MCP-клиенту или LLM.
Полное значение ключа показывается только при создании.
## Секреты
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.85.0"
channel = "1.96.1"
components = ["clippy", "rustfmt"]