Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a02acf5db3 | |||
| 9a7d60593a | |||
| 0e8f1ca03a | |||
| 99bd05c145 | |||
| 63f8ee333f | |||
| 0241d186ea | |||
| 46892ee61c | |||
| 8318e4b560 | |||
| 626f2845e2 | |||
| 502e339809 | |||
| dca97bd69b | |||
| 061873058e | |||
| c98c7c8ce2 | |||
| fd8571ad10 | |||
| c7e5efa976 | |||
| 4da13c0811 | |||
| 4cad7f1c46 | |||
| 700a684257 | |||
| 2b2ff92146 | |||
| d34c8a73d6 | |||
| 9ba2aa3f38 | |||
| 209b3e1485 | |||
| 3b51cb89df | |||
| 861502aabc | |||
| 327bea6f33 | |||
| 87d9ba2299 | |||
| de1bcc5cae | |||
| 2f6e1d5e51 | |||
| 0d828257c0 | |||
| 8b8f2fc6c5 | |||
| 7aad3b1228 | |||
| 8ce00ede31 | |||
| 267061e226 | |||
| 78d3052a61 | |||
| 0d193b84dd | |||
| 8a1cc1746f | |||
| d83ab541d9 | |||
| 5922aea68f |
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
jobs = 2
|
||||
@@ -24,11 +24,42 @@ CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
|
||||
# Публичные узлы разрешены по умолчанию. Для внутренних API перечислите
|
||||
# допустимые имена или IP через запятую.
|
||||
CRANK_OUTBOUND_ALLOWED_HOSTS=
|
||||
CRANK_OUTBOUND_DENIED_HOSTS=
|
||||
CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304
|
||||
CRANK_ENVIRONMENT=development
|
||||
CRANK_LOG_LEVEL=info
|
||||
# Пустое значение отключает канал критических ошибок.
|
||||
CRANK_SENTRY_DSN=
|
||||
# Prometheus endpoints use separate listeners and stay on loopback by default.
|
||||
CRANK_METRICS_ENABLED=true
|
||||
CRANK_ADMIN_METRICS_BIND=127.0.0.1:9464
|
||||
CRANK_MCP_METRICS_BIND=127.0.0.1:9465
|
||||
# Required when either metrics listener uses a non-loopback address.
|
||||
CRANK_METRICS_BEARER_TOKEN=
|
||||
CRANK_INVOCATION_LOG_RETENTION_DAYS=30
|
||||
# Пустой endpoint полностью отключает экспорт трасс.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=
|
||||
OTEL_EXPORTER_OTLP_TIMEOUT=10000
|
||||
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT=
|
||||
OTEL_EXPORTER_OTLP_HEADERS=
|
||||
OTEL_EXPORTER_OTLP_TRACES_HEADERS=
|
||||
OTEL_BSP_MAX_QUEUE_SIZE=2048
|
||||
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
|
||||
OTEL_BSP_SCHEDULE_DELAY=5000
|
||||
OTEL_BSP_EXPORT_TIMEOUT=30000
|
||||
CRANK_MASTER_KEY=change-me-master-key
|
||||
CRANK_SESSION_SECRET=change-me-session-secret
|
||||
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||
CRANK_SESSION_TTL_HOURS=24
|
||||
# Trust X-Real-IP / X-Forwarded-For for client rate limiting. Enable only when
|
||||
# admin-api runs behind the bundled nginx (or another trusted reverse proxy).
|
||||
CRANK_TRUST_FORWARDED_HEADERS=true
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||
|
||||
+174
-48
@@ -22,6 +22,29 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
set -eu
|
||||
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
||||
if [ -z "$toolchain" ]; then
|
||||
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
||||
rustup default "$toolchain"
|
||||
host="$(rustc -vV | sed -n 's/^host: //p')"
|
||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
||||
toolchain_bin="$toolchain_dir/bin"
|
||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||
"$toolchain_bin/rustc" --version
|
||||
"$toolchain_bin/cargo" --version
|
||||
"$toolchain_bin/rustfmt" --version
|
||||
"$toolchain_bin/cargo-clippy" --version
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
python3 --version
|
||||
@@ -32,6 +55,9 @@ jobs:
|
||||
docker --version
|
||||
docker info
|
||||
|
||||
- name: Install dependency policy tool
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
|
||||
- name: Run tooling unit tests
|
||||
run: python3 -m unittest discover -s tests/unit
|
||||
|
||||
@@ -44,6 +70,9 @@ jobs:
|
||||
- name: Check Rust code health
|
||||
run: scripts/check-rust-code-health.sh
|
||||
|
||||
- name: Check dependency licenses and advisories
|
||||
run: cargo deny --locked check advisories bans licenses sources
|
||||
|
||||
- name: Check Rust boundaries
|
||||
run: scripts/check-rust-boundaries.sh
|
||||
|
||||
@@ -72,6 +101,10 @@ jobs:
|
||||
working-directory: apps/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Audit UI dependencies
|
||||
working-directory: apps/ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Build UI bundle
|
||||
working-directory: apps/ui
|
||||
run: npm run build
|
||||
@@ -104,6 +137,29 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
set -eu
|
||||
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
||||
if [ -z "$toolchain" ]; then
|
||||
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
||||
rustup default "$toolchain"
|
||||
host="$(rustc -vV | sed -n 's/^host: //p')"
|
||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
||||
toolchain_bin="$toolchain_dir/bin"
|
||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||
"$toolchain_bin/rustc" --version
|
||||
"$toolchain_bin/cargo" --version
|
||||
"$toolchain_bin/rustfmt" --version
|
||||
"$toolchain_bin/cargo-clippy" --version
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
rustc --version
|
||||
@@ -133,9 +189,11 @@ jobs:
|
||||
find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true
|
||||
|
||||
deployment:
|
||||
name: Deployment Manifests
|
||||
name: Community Image Smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs: ui
|
||||
needs:
|
||||
- rust
|
||||
- ui
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -144,6 +202,65 @@ jobs:
|
||||
- name: Validate Community deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
|
||||
- name: Build Community images
|
||||
run: |
|
||||
docker build -f apps/admin-api/Dockerfile -t crank/admin-api:ci .
|
||||
docker build -f apps/mcp-server/Dockerfile -t crank/mcp-server:ci .
|
||||
docker build -f apps/ui/Dockerfile -t crank/ui:ci .
|
||||
|
||||
- name: Start Community image stack
|
||||
run: |
|
||||
mkdir -p .tmp
|
||||
cat > .tmp/community-smoke.env <<'EOF'
|
||||
COMPOSE_PROJECT_NAME=crank-ci-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_PUBLISH_PORT=0
|
||||
POSTGRES_DB=crank
|
||||
POSTGRES_USER=crank
|
||||
POSTGRES_PASSWORD=crank-ci-password
|
||||
CRANK_ADMIN_API_IMAGE=crank/admin-api:ci
|
||||
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:ci
|
||||
CRANK_UI_IMAGE=crank/ui:ci
|
||||
CRANK_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
CRANK_SESSION_SECRET=ci-session-secret
|
||||
CRANK_PASSWORD_PEPPER=ci-password-pepper
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=ci-admin-password
|
||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=CI Owner
|
||||
CRANK_BASE_URL=http://127.0.0.1
|
||||
CRANK_PUBLISH_BIND=127.0.0.1
|
||||
CRANK_ADMIN_PUBLISH_PORT=0
|
||||
CRANK_MCP_PUBLISH_PORT=0
|
||||
CRANK_UI_PUBLISH_PORT=0
|
||||
CRANK_DEMO_SEED=true
|
||||
EOF
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db up -d --wait
|
||||
ui_address="$(docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env port ui 3000)"
|
||||
printf 'http://%s\n' "$ui_address" > .tmp/community-smoke.url
|
||||
|
||||
- name: Run authenticated Community image smoke
|
||||
env:
|
||||
CRANK_STAGING_ADMIN_EMAIL: owner@crank.test
|
||||
CRANK_STAGING_ADMIN_PASSWORD: ci-admin-password
|
||||
run: scripts/authenticated-product-smoke.sh "$(cat .tmp/community-smoke.url)"
|
||||
|
||||
- name: Show Community image logs
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db ps || true
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db logs --no-color || true
|
||||
|
||||
- name: Stop Community image stack
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db down -v --remove-orphans || true
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
@@ -199,6 +316,10 @@ jobs:
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.UI_IMAGE }}:main' \
|
||||
.
|
||||
scripts/scan-images.sh \
|
||||
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
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 }}'
|
||||
@@ -228,9 +349,14 @@ jobs:
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH'"
|
||||
"mkdir -p '$DEPLOY_PATH' && \
|
||||
if [ -f '$DEPLOY_PATH/docker-compose.yml' ]; then \
|
||||
cp '$DEPLOY_PATH/docker-compose.yml' '$DEPLOY_PATH/docker-compose.previous.yml'; \
|
||||
fi"
|
||||
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||
rsync -az -e "ssh -p $DEPLOY_PORT" scripts/deploy-community.sh \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/deploy-community.sh"
|
||||
|
||||
- name: Write environment file
|
||||
run: |
|
||||
@@ -246,6 +372,11 @@ jobs:
|
||||
append_if_set POSTGRES_USER "$POSTGRES_USER"
|
||||
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
||||
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
|
||||
append_if_set POSTGRES_MAX_CONNECTIONS "${POSTGRES_MAX_CONNECTIONS:-}"
|
||||
append_if_set POSTGRES_MIN_CONNECTIONS "${POSTGRES_MIN_CONNECTIONS:-}"
|
||||
append_if_set POSTGRES_ACQUIRE_TIMEOUT_MS "${POSTGRES_ACQUIRE_TIMEOUT_MS:-}"
|
||||
append_if_set POSTGRES_IDLE_TIMEOUT_MS "${POSTGRES_IDLE_TIMEOUT_MS:-}"
|
||||
append_if_set POSTGRES_MAX_LIFETIME_MS "${POSTGRES_MAX_LIFETIME_MS:-}"
|
||||
if [ -n "${POSTGRES_PORT:-}" ]; then
|
||||
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
|
||||
elif [ -n "${PGBOUNCER_PORT:-}" ]; then
|
||||
@@ -256,7 +387,35 @@ jobs:
|
||||
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_ADMIN_RATE_LIMIT_RPS "${CRANK_ADMIN_RATE_LIMIT_RPS:-}"
|
||||
append_if_set CRANK_ADMIN_RATE_LIMIT_BURST "${CRANK_ADMIN_RATE_LIMIT_BURST:-}"
|
||||
append_if_set CRANK_MCP_RATE_LIMIT_RPS "${CRANK_MCP_RATE_LIMIT_RPS:-}"
|
||||
append_if_set CRANK_MCP_RATE_LIMIT_BURST "${CRANK_MCP_RATE_LIMIT_BURST:-}"
|
||||
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_UNARY "${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-}"
|
||||
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS "${CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS:-}"
|
||||
append_if_set CRANK_OUTBOUND_ALLOWED_HOSTS "${CRANK_OUTBOUND_ALLOWED_HOSTS:-}"
|
||||
append_if_set CRANK_OUTBOUND_DENIED_HOSTS "${CRANK_OUTBOUND_DENIED_HOSTS:-}"
|
||||
append_if_set CRANK_OUTBOUND_MAX_RESPONSE_BYTES "${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-}"
|
||||
append_if_set CRANK_ENVIRONMENT "${CRANK_ENVIRONMENT:-production}"
|
||||
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
||||
append_if_set CRANK_SENTRY_DSN "${CRANK_SENTRY_DSN:-}"
|
||||
append_if_set CRANK_METRICS_ENABLED "${CRANK_METRICS_ENABLED:-}"
|
||||
append_if_set CRANK_ADMIN_METRICS_BIND "${CRANK_ADMIN_METRICS_BIND:-}"
|
||||
append_if_set CRANK_MCP_METRICS_BIND "${CRANK_MCP_METRICS_BIND:-}"
|
||||
append_if_set CRANK_METRICS_BEARER_TOKEN "${CRANK_METRICS_BEARER_TOKEN:-}"
|
||||
append_if_set CRANK_INVOCATION_LOG_RETENTION_DAYS "${CRANK_INVOCATION_LOG_RETENTION_DAYS:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_ENDPOINT "${OTEL_EXPORTER_OTLP_ENDPOINT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT "${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_PROTOCOL "${OTEL_EXPORTER_OTLP_PROTOCOL:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_PROTOCOL "${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TIMEOUT "${OTEL_EXPORTER_OTLP_TIMEOUT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_TIMEOUT "${OTEL_EXPORTER_OTLP_TRACES_TIMEOUT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_HEADERS "${OTEL_EXPORTER_OTLP_HEADERS:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_HEADERS "${OTEL_EXPORTER_OTLP_TRACES_HEADERS:-}"
|
||||
append_if_set OTEL_BSP_MAX_QUEUE_SIZE "${OTEL_BSP_MAX_QUEUE_SIZE:-}"
|
||||
append_if_set OTEL_BSP_MAX_EXPORT_BATCH_SIZE "${OTEL_BSP_MAX_EXPORT_BATCH_SIZE:-}"
|
||||
append_if_set OTEL_BSP_SCHEDULE_DELAY "${OTEL_BSP_SCHEDULE_DELAY:-}"
|
||||
append_if_set OTEL_BSP_EXPORT_TIMEOUT "${OTEL_BSP_EXPORT_TIMEOUT:-}"
|
||||
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"
|
||||
@@ -276,7 +435,10 @@ jobs:
|
||||
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'"
|
||||
"mkdir -p '$DEPLOY_PATH' && \
|
||||
if [ -f '$DEPLOY_PATH/.env' ]; then \
|
||||
cp '$DEPLOY_PATH/.env' '$DEPLOY_PATH/.env.previous'; \
|
||||
fi && cat > '$DEPLOY_PATH/.env'"
|
||||
rm -f "$tmp_env"
|
||||
|
||||
- name: Validate required environment variables
|
||||
@@ -310,48 +472,12 @@ jobs:
|
||||
- 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
|
||||
"
|
||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | ssh -p "$DEPLOY_PORT" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"chmod 700 '$DEPLOY_PATH/deploy-community.sh' && \
|
||||
'$DEPLOY_PATH/deploy-community.sh' '$DEPLOY_PATH'"
|
||||
|
||||
- name: Verify health endpoints
|
||||
run: |
|
||||
@@ -361,8 +487,8 @@ jobs:
|
||||
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
|
||||
&& curl --fail --silent http://127.0.0.1:3001/ready >/dev/null \
|
||||
&& curl --fail --silent http://127.0.0.1:3002/ready >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
@@ -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
|
||||
@@ -32,6 +49,16 @@ jobs:
|
||||
command -v bao
|
||||
bao version
|
||||
|
||||
- name: Install dependency policy tool
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
|
||||
- name: Run release quality gates
|
||||
run: |
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
cargo deny --locked check advisories bans licenses sources
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --release -p admin-api -p mcp-server
|
||||
|
||||
@@ -39,10 +66,25 @@ jobs:
|
||||
working-directory: apps/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Audit UI dependencies
|
||||
working-directory: apps/ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Build UI dist
|
||||
working-directory: apps/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: apps/ui
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run release end-to-end tests
|
||||
working-directory: apps/ui
|
||||
run: npm run e2e
|
||||
|
||||
- name: Validate deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
|
||||
- name: Package release artifacts
|
||||
run: |
|
||||
mkdir -p dist/release
|
||||
@@ -88,6 +130,10 @@ jobs:
|
||||
docker build -f apps/ui/Dockerfile \
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.UI_IMAGE }}:latest' .
|
||||
scripts/scan-images.sh \
|
||||
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:latest'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
|
||||
Generated
+1284
-826
File diff suppressed because it is too large
Load Diff
+35
-7
@@ -8,9 +8,11 @@ members = [
|
||||
"crates/crank-import",
|
||||
"crates/crank-schema",
|
||||
"crates/crank-mapping",
|
||||
"crates/crank-observability",
|
||||
"crates/crank-registry",
|
||||
"crates/crank-runtime",
|
||||
"crates/crank-test-support",
|
||||
"crates/crank-trace",
|
||||
"crates/crank-adapter-rest",
|
||||
]
|
||||
resolver = "3"
|
||||
@@ -18,28 +20,54 @@ resolver = "3"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.96"
|
||||
version = "0.3.1"
|
||||
publish = false
|
||||
|
||||
[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"
|
||||
metrics = "0.24.6"
|
||||
metrics-exporter-prometheus = { version = "0.18.3", default-features = false }
|
||||
opentelemetry = { version = "0.32.0", default-features = false, features = ["trace"] }
|
||||
opentelemetry-otlp = { version = "0.32.0", default-features = false, features = ["http-proto", "reqwest-blocking-client", "reqwest-rustls", "trace"] }
|
||||
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["gen-tonic-messages", "trace"] }
|
||||
opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["trace"] }
|
||||
percent-encoding = "2"
|
||||
prost = "0.14"
|
||||
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"
|
||||
sentry = { version = "0.49.0", default-features = false, features = ["backtrace", "panic", "rustls", "ureq"] }
|
||||
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"] }
|
||||
subtle = "2.6"
|
||||
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"] }
|
||||
tower = "0.5"
|
||||
tracing = "0.1"
|
||||
tracing-opentelemetry = { version = "0.33.0", default-features = false }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
url = "2"
|
||||
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"] }
|
||||
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
[profile.test]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test.package."*"]
|
||||
debug = false
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "admin-api"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -18,9 +19,12 @@ crank-community-auth = { path = "../../crates/crank-community-auth" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-import = { path = "../../crates/crank-import" }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-observability = { path = "../../crates/crank-observability" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
crank-trace = { path = "../../crates/crank-trace" }
|
||||
metrics.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -37,5 +41,9 @@ uuid.workspace = true
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
crank-test-support = { path = "../../crates/crank-test-support" }
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
reqwest.workspace = true
|
||||
serial_test = "3"
|
||||
tower.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,14 +12,17 @@ use crate::{
|
||||
agents::{
|
||||
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
|
||||
delete_agent_platform_api_key, get_agent, get_agent_version,
|
||||
list_agent_platform_api_keys, list_agents, publish_agent,
|
||||
list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent,
|
||||
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
|
||||
},
|
||||
auth::{change_password, get_profile, get_session, login, logout, update_profile},
|
||||
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,
|
||||
@@ -80,6 +83,7 @@ pub fn build_app(state: AppState) -> Router {
|
||||
)
|
||||
.route("/operations/{operation_id}/export", get(export_operation))
|
||||
.route("/agents", get(list_agents).post(create_agent))
|
||||
.route("/agents/tool-search/preview", post(preview_tool_search))
|
||||
.route(
|
||||
"/agents/{agent_id}",
|
||||
get(get_agent).patch(update_agent).delete(delete_agent),
|
||||
@@ -123,6 +127,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));
|
||||
@@ -160,6 +166,7 @@ pub fn build_app(state: AppState) -> Router {
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(crate::routes::health))
|
||||
.route("/ready", get(crate::routes::readiness))
|
||||
.nest(
|
||||
"/api/auth",
|
||||
Router::new()
|
||||
@@ -172,6 +179,9 @@ pub fn build_app(state: AppState) -> Router {
|
||||
apply_api_rate_limit,
|
||||
))
|
||||
.layer(middleware::from_fn(apply_request_context))
|
||||
.layer(middleware::from_fn(
|
||||
crank_observability::record_http_request,
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_registry::{
|
||||
@@ -144,7 +144,7 @@ pub struct AgentPayload {
|
||||
#[serde(default)]
|
||||
pub instructions: Value,
|
||||
#[serde(default)]
|
||||
pub tool_selection_policy: Value,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -165,6 +165,43 @@ pub struct AgentBindingPayload {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ToolSearchPreviewPayload {
|
||||
pub query: String,
|
||||
#[serde(default)]
|
||||
pub group_ids: Vec<String>,
|
||||
pub bindings: Vec<AgentBindingPayload>,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum AgentCatalogPayload {
|
||||
Bindings(Vec<AgentBindingPayload>),
|
||||
Config {
|
||||
bindings: Vec<AgentBindingPayload>,
|
||||
tool_selection_policy: ToolSelectionPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
impl AgentCatalogPayload {
|
||||
pub fn into_parts(self) -> (Vec<AgentBindingPayload>, Option<ToolSelectionPolicy>) {
|
||||
match self {
|
||||
Self::Bindings(bindings) => (bindings, None),
|
||||
Self::Config {
|
||||
bindings,
|
||||
tool_selection_policy,
|
||||
} => (bindings, Some(tool_selection_policy)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<AgentBindingPayload>> for AgentCatalogPayload {
|
||||
fn from(bindings: Vec<AgentBindingPayload>) -> Self {
|
||||
Self::Bindings(bindings)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CreatedAgentResponse {
|
||||
pub agent_id: String,
|
||||
@@ -196,6 +233,7 @@ pub struct AgentSummaryView {
|
||||
pub published_at: Option<String>,
|
||||
pub operation_count: usize,
|
||||
pub operation_ids: Vec<String>,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
pub key_count: usize,
|
||||
pub calls_today: u64,
|
||||
pub mcp_endpoint: String,
|
||||
@@ -211,7 +249,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)]
|
||||
@@ -221,7 +269,12 @@ pub struct CreatedPlatformApiKeyResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct WorkspaceExportResponse {
|
||||
pub struct WorkspaceCatalogSnapshotResponse {
|
||||
pub kind: String,
|
||||
pub format_version: String,
|
||||
pub restorable: bool,
|
||||
pub included: Vec<String>,
|
||||
pub excluded: Vec<String>,
|
||||
pub workspace: WorkspaceRecord,
|
||||
pub operations: Vec<OperationSummaryView>,
|
||||
pub agents: Vec<AgentSummaryView>,
|
||||
@@ -240,6 +293,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>,
|
||||
|
||||
+30
-10
@@ -137,16 +137,24 @@ impl ApiError {
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
match &self {
|
||||
Self::Internal { message, .. } => {
|
||||
error!(error_code = self.code(), error_message = %message)
|
||||
Self::Internal { .. } => {
|
||||
error!(
|
||||
name: "admin.response.internal_error",
|
||||
error_code = self.code(),
|
||||
"internal API error response"
|
||||
)
|
||||
}
|
||||
Self::Unauthorized { message, .. }
|
||||
| Self::Forbidden { message, .. }
|
||||
| Self::Validation { message, .. }
|
||||
| Self::NotFound { message, .. }
|
||||
| Self::Conflict { message, .. }
|
||||
| Self::RateLimited { message, .. } => {
|
||||
warn!(error_code = self.code(), error_message = %message)
|
||||
Self::Unauthorized { .. }
|
||||
| Self::Forbidden { .. }
|
||||
| Self::Validation { .. }
|
||||
| Self::NotFound { .. }
|
||||
| Self::Conflict { .. }
|
||||
| Self::RateLimited { .. } => {
|
||||
warn!(
|
||||
name: "admin.response.rejected",
|
||||
error_code = self.code(),
|
||||
"API request rejected"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +359,10 @@ impl From<RegistryError> for ApiError {
|
||||
format!("import job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::ImportJobAlreadyApplied { job_id } => Self::conflict_with_context(
|
||||
format!("import job {job_id} was already applied with different parameters"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
@@ -407,6 +419,10 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "runtime_idempotency_unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "runtime_idempotency_in_progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "runtime_idempotency_conflict",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "runtime_idempotency_outcome_unknown",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
@@ -434,7 +450,11 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
"safety_class": safety_class,
|
||||
})),
|
||||
RuntimeError::InvalidConfirmationToken { operation_id }
|
||||
| RuntimeError::ConfirmationStoreUnavailable { operation_id } => Some(json!({
|
||||
| RuntimeError::ConfirmationStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyInProgress { operation_id }
|
||||
| RuntimeError::IdempotencyConflict { operation_id }
|
||||
| RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
})),
|
||||
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
|
||||
|
||||
@@ -175,6 +175,7 @@ mod tests {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
+112
-12
@@ -1,4 +1,4 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
use std::{env, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
@@ -7,22 +7,50 @@ use admin_api::{
|
||||
state::AppState,
|
||||
};
|
||||
use crank_community_auth::PasswordIdentityProvider;
|
||||
use crank_observability::{
|
||||
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
|
||||
capture_critical_error,
|
||||
};
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto, community_default,
|
||||
RuntimeLimits, SecretCrypto,
|
||||
};
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use sqlx::{PgPool, postgres::PgConnectOptions};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
let observability = crank_observability::init(ObservabilityConfig::from_env(
|
||||
"admin-api",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"admin_api=info,tower_http=info",
|
||||
)?)?;
|
||||
let mut startup_completed = false;
|
||||
let result = run(&observability, &mut startup_completed).await;
|
||||
if result.is_err() {
|
||||
capture_critical_error(if startup_completed {
|
||||
CriticalErrorCategory::Internal
|
||||
} else {
|
||||
CriticalErrorCategory::Startup
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(
|
||||
observability: &ObservabilityLifecycle,
|
||||
startup_completed: &mut bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let metrics_config =
|
||||
MetricsConfig::from_env("CRANK_ADMIN_METRICS_BIND", "127.0.0.1:9464".parse()?)?;
|
||||
let metrics_enabled = metrics_config.enabled();
|
||||
let metrics_server = if metrics_config.enabled() {
|
||||
Some(observability.metrics_surface(metrics_config).bind().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let storage_root = PathBuf::from(
|
||||
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
|
||||
@@ -36,6 +64,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
if metrics_enabled {
|
||||
spawn_postgres_pool_metrics(registry.pool().clone());
|
||||
}
|
||||
let auth_settings = AuthSettings {
|
||||
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||
@@ -56,7 +87,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let runtime = community_default()
|
||||
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::from_env()?;
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
|
||||
.with_limits(runtime_limits)
|
||||
.with_response_cache(cache_stores.response.clone())
|
||||
.with_coordination_store(cache_stores.coordination.clone())
|
||||
@@ -70,12 +102,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_outbound_http_policy(outbound_http_policy)
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
let invocation_log_retention_days =
|
||||
positive_i64_from_env("CRANK_INVOCATION_LOG_RETENTION_DAYS", 30)?;
|
||||
service.bootstrap_admin_user().await?;
|
||||
if env_flag("CRANK_DEMO_SEED") {
|
||||
service.seed_demo_assets().await?;
|
||||
}
|
||||
spawn_invocation_log_cleanup(service.clone(), invocation_log_retention_days);
|
||||
let state = AppState {
|
||||
service,
|
||||
api_rate_limiter: if cache_config.backend.is_external() {
|
||||
@@ -83,11 +119,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else {
|
||||
RequestRateLimiter::new(api_rate_limit)
|
||||
},
|
||||
trust_forwarded_headers: env_flag("CRANK_TRUST_FORWARDED_HEADERS"),
|
||||
};
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
|
||||
|
||||
info!(
|
||||
name: "admin.postgres_pool.configured",
|
||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||
admin_rate_limit_rps = api_rate_limit.requests_per_second,
|
||||
admin_rate_limit_burst = api_rate_limit.burst,
|
||||
@@ -97,15 +136,76 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||
invocation_log_retention_days,
|
||||
"postgres pool configured"
|
||||
);
|
||||
info!("admin-api listening on {}", socket_addr);
|
||||
info!(
|
||||
name: "admin.server.listening",
|
||||
bind_address = %socket_addr,
|
||||
"admin-api listening"
|
||||
);
|
||||
*startup_completed = true;
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
if let Some(metrics_server) = metrics_server {
|
||||
tokio::select! {
|
||||
result = axum::serve(listener, make_service) => result?,
|
||||
result = metrics_server.serve() => result?,
|
||||
}
|
||||
} else {
|
||||
axum::serve(listener, make_service).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn positive_i64_from_env(
|
||||
name: &'static str,
|
||||
default: i64,
|
||||
) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let value = match env::var(name) {
|
||||
Ok(raw) => raw.parse::<i64>()?,
|
||||
Err(env::VarError::NotPresent) => default,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if value <= 0 {
|
||||
return Err(format!("{name} must be greater than zero").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, retention_days: i64) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60 * 60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days);
|
||||
match service.cleanup_invocation_logs_before(cutoff).await {
|
||||
Ok(removed) if removed > 0 => info!(
|
||||
name: "admin.invocation_log_cleanup.completed",
|
||||
removed,
|
||||
"expired invocation logs removed"
|
||||
),
|
||||
Ok(_) => {}
|
||||
Err(_) => warn!(
|
||||
name: "admin.invocation_log_cleanup.failed",
|
||||
error_category = "registry_cleanup",
|
||||
"failed to remove expired invocation logs"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_postgres_pool_metrics(pool: PgPool) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
crank_observability::record_db_pool_connections(pool.size(), pool.num_idle());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
matches!(
|
||||
env::var(name)
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::{COOKIE, HeaderMap},
|
||||
extract::{ConnectInfo, Request, State},
|
||||
http::HeaderMap,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crank_runtime::RateLimitRejection;
|
||||
use crank_runtime::{RateLimitCheckError, RateLimitRejection};
|
||||
|
||||
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub async fn apply_api_rate_limit(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let key = rate_limit_key(request.headers(), request.uri().path());
|
||||
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
|
||||
return Err(ApiError::rate_limited_with_context(
|
||||
"request rate limit exceeded",
|
||||
rejection_context(rejection),
|
||||
));
|
||||
let peer_ip = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ConnectInfo(address)| address.ip());
|
||||
let key = rate_limit_key(
|
||||
request.headers(),
|
||||
request.uri().path(),
|
||||
peer_ip,
|
||||
state.trust_forwarded_headers,
|
||||
);
|
||||
if let Err(error) = state.api_rate_limiter.check(&key).await {
|
||||
return match error {
|
||||
RateLimitCheckError::Rejected(rejection) => Err(ApiError::rate_limited_with_context(
|
||||
"request rate limit exceeded",
|
||||
rejection_context(rejection),
|
||||
)),
|
||||
RateLimitCheckError::StoreUnavailable => {
|
||||
Err(ApiError::internal("rate limit service unavailable"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
@@ -30,56 +46,64 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
|
||||
if let Some(session_id) = session_id_from_headers(headers) {
|
||||
return format!("session:{session_id}");
|
||||
fn rate_limit_key(
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
peer_ip: Option<IpAddr>,
|
||||
trust_forwarded_headers: bool,
|
||||
) -> String {
|
||||
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
|
||||
return format!("ip:{client_ip}");
|
||||
}
|
||||
|
||||
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") {
|
||||
let ip = forwarded_for
|
||||
.split(',')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown");
|
||||
return format!("ip:{ip}");
|
||||
}
|
||||
|
||||
if let Some(real_ip) = header_value(headers, "x-real-ip") {
|
||||
return format!("ip:{real_ip}");
|
||||
if let Some(peer_ip) = peer_ip {
|
||||
return format!("ip:{peer_ip}");
|
||||
}
|
||||
|
||||
format!("anonymous:{path}")
|
||||
}
|
||||
|
||||
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
let cookies = headers.get(COOKIE)?.to_str().ok()?;
|
||||
for part in cookies.split(';') {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
if name != SESSION_COOKIE_NAME {
|
||||
continue;
|
||||
}
|
||||
let (session_id, _) = value.split_once('.')?;
|
||||
if !session_id.is_empty() {
|
||||
return Some(session_id.to_owned());
|
||||
}
|
||||
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
|
||||
///
|
||||
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
|
||||
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
|
||||
/// peer, so the last entry is the trustworthy hop; taking the first entry (as
|
||||
/// naive implementations do) would let a client spoof its address by sending a
|
||||
/// pre-populated header.
|
||||
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
if let Some(real_ip) = header_value(headers, "x-real-ip").and_then(parse_ip) {
|
||||
return Some(real_ip);
|
||||
}
|
||||
|
||||
None
|
||||
header_value(headers, "x-forwarded-for")?
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.rfind(|value| !value.is_empty())
|
||||
.and_then(parse_ip)
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
||||
headers.get(name)?.to_str().ok().map(str::trim)
|
||||
}
|
||||
|
||||
fn parse_ip(value: &str) -> Option<IpAddr> {
|
||||
value.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||
|
||||
use super::rate_limit_key;
|
||||
|
||||
fn peer() -> Option<IpAddr> {
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_by_session_cookie_first() {
|
||||
fn unverified_session_cookie_cannot_change_client_key() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
COOKIE,
|
||||
@@ -88,19 +112,86 @@ mod tests {
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login"),
|
||||
"session:sess_123"
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:10.0.0.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_forwarded_ip() {
|
||||
fn ignores_forwarded_headers_when_untrusted() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), false),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_real_ip_when_trusted() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
|
||||
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
||||
);
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:10.0.0.9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_last_forwarded_hop_when_trusted() {
|
||||
// A client can prepend spoofed entries; the trusted proxy appends the
|
||||
// real peer, so the last entry is authoritative.
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
||||
);
|
||||
|
||||
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:10.0.0.6"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_peer_ip_without_headers() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_forwarded_ip_values() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("198.51.100.8, also-not-an-ip"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_path_without_peer() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", None, false),
|
||||
"anonymous:/api/auth/login"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderMap, HeaderName, HeaderValue},
|
||||
http::{HeaderName, HeaderValue},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
use crank_observability::{RequestId, set_remote_trace_parent, with_request_correlation};
|
||||
use tracing::{Instrument, info, info_span};
|
||||
|
||||
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
||||
const MAX_REQUEST_ID_LEN: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestContext {
|
||||
@@ -17,149 +16,48 @@ pub struct RequestContext {
|
||||
|
||||
pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
|
||||
let context = RequestContext {
|
||||
request_id: resolve_request_id(request.headers()),
|
||||
request_id: RequestId::resolve_from_headers(request.headers()).into_string(),
|
||||
};
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_owned();
|
||||
let span = info_span!(
|
||||
target: "crank::trace",
|
||||
"http.request",
|
||||
request_id = %context.request_id,
|
||||
);
|
||||
set_remote_trace_parent(&span, request.headers());
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
info!(
|
||||
request_id = %context.request_id,
|
||||
method = %method,
|
||||
path,
|
||||
status = response.status().as_u16(),
|
||||
"admin request completed"
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn resolve_request_id(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get(&REQUEST_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| is_valid_request_id(value))
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| Uuid::now_v7().to_string())
|
||||
}
|
||||
|
||||
fn is_valid_request_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_REQUEST_ID_LEN
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';')
|
||||
with_request_correlation(context.request_id.clone(), async move {
|
||||
let mut response = next.run(request).instrument(span).await;
|
||||
info!(
|
||||
name: "admin.request.completed",
|
||||
request_id = %context.request_id,
|
||||
method = %method,
|
||||
path,
|
||||
status = response.status().as_u16(),
|
||||
"admin request completed"
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||
}
|
||||
response
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use reqwest::Client;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
|
||||
use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id};
|
||||
|
||||
#[test]
|
||||
fn accepts_visible_ascii_request_ids() {
|
||||
assert!(is_valid_request_id("req_test_123"));
|
||||
assert!(is_valid_request_id("trace-123/abc"));
|
||||
assert!(crank_observability::RequestId::is_valid("req_test_123"));
|
||||
assert!(crank_observability::RequestId::is_valid("trace-123/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_control_request_ids() {
|
||||
assert!(!is_valid_request_id(""));
|
||||
assert!(!is_valid_request_id("bad value"));
|
||||
assert!(!is_valid_request_id("bad\nvalue"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_request_completion_with_request_id() {
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(writer.clone())
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.with_filter(LevelFilter::INFO),
|
||||
);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let app = Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(apply_request_context));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let response = Client::new()
|
||||
.get(format!("http://{address}/probe"))
|
||||
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"req_admin_trace_123"
|
||||
);
|
||||
|
||||
let logs = writer.output();
|
||||
assert!(logs.contains("admin request completed"));
|
||||
assert!(logs.contains("req_admin_trace_123"));
|
||||
assert!(logs.contains("GET"));
|
||||
assert!(logs.contains("/probe"));
|
||||
assert!(logs.contains("status=200"));
|
||||
assert!(!crank_observability::RequestId::is_valid(""));
|
||||
assert!(!crank_observability::RequestId::is_valid("bad value"));
|
||||
assert!(!crank_observability::RequestId::is_valid("bad\nvalue"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,36 @@ pub mod secrets;
|
||||
pub mod upstreams;
|
||||
pub mod workspaces;
|
||||
|
||||
use axum::Json;
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "ok"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.service.readiness().await {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "ready",
|
||||
"checks": { "postgres": "ready" }
|
||||
})),
|
||||
),
|
||||
Err(error) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "not_ready",
|
||||
"checks": { "postgres": "not_ready" },
|
||||
"error": error.to_string()
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub async fn export_workspace(
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let exported = state
|
||||
.service
|
||||
.export_workspace(&path.workspace_id.as_str().into())
|
||||
.export_workspace_catalog_snapshot(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!(exported)))
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
UpdateAgentPayload,
|
||||
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -50,6 +50,18 @@ pub async fn list_agents(
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn preview_tool_search(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ToolSearchPreviewPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.preview_tool_search(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!({"items": items})))
|
||||
}
|
||||
|
||||
pub async fn create_agent(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -124,7 +136,7 @@ pub async fn get_agent_version(
|
||||
pub async fn save_agent_bindings(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Vec<AgentBindingPayload>>,
|
||||
Json(payload): Json<AgentCatalogPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let record = state
|
||||
.service
|
||||
|
||||
@@ -103,7 +103,7 @@ pub async fn change_password(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.change_password(&session.user.id, payload)
|
||||
.change_password(&session.user.id, &session.session_id, payload)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
+289
-55
@@ -5,21 +5,25 @@ use std::sync::Arc;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::{
|
||||
AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities,
|
||||
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId, NoopAuditSink,
|
||||
OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine, ProductEdition, Protocol,
|
||||
ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind, ToolQualitySchemaNode,
|
||||
UsagePeriod, WorkspaceId,
|
||||
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId,
|
||||
InvocationSource, NoopAuditSink, OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine,
|
||||
ProductEdition, Protocol, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind,
|
||||
ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
AgentSummary, CreateInvocationLogRequest, OperationAgentRef, OperationSummary,
|
||||
OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
|
||||
AgentSummary, CreateInvocationLogRequest, InvocationHistoryWriteOutcome, OperationAgentRef,
|
||||
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
|
||||
};
|
||||
use crank_runtime::{
|
||||
OutboundHttpPolicy, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
|
||||
};
|
||||
use crank_runtime::{PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::Instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod agents;
|
||||
@@ -38,7 +42,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_execution_timeout, validate_idempotency_policy,
|
||||
validate_protocol_target, validate_response_cache_policy,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -52,6 +57,7 @@ pub struct AdminService {
|
||||
policy_engine: Arc<dyn PolicyEngine>,
|
||||
audit_sink: Arc<dyn AuditSink>,
|
||||
capability_profile: Arc<dyn CapabilityProfile>,
|
||||
outbound_http_policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
pub struct AdminServiceBuilder {
|
||||
@@ -64,11 +70,17 @@ pub struct AdminServiceBuilder {
|
||||
policy_engine: Option<Arc<dyn PolicyEngine>>,
|
||||
audit_sink: Option<Arc<dyn AuditSink>>,
|
||||
capability_profile: Option<Arc<dyn CapabilityProfile>>,
|
||||
outbound_http_policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
pub use crate::dto::*;
|
||||
|
||||
impl AdminService {
|
||||
pub async fn readiness(&self) -> Result<(), ApiError> {
|
||||
self.registry.ping().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new(
|
||||
registry: PostgresRegistry,
|
||||
@@ -138,6 +150,7 @@ impl AdminServiceBuilder {
|
||||
policy_engine: None,
|
||||
audit_sink: None,
|
||||
capability_profile: None,
|
||||
outbound_http_policy: OutboundHttpPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +159,11 @@ impl AdminServiceBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_outbound_http_policy(mut self, policy: OutboundHttpPolicy) -> Self {
|
||||
self.outbound_http_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self {
|
||||
self.policy_engine = Some(policy_engine);
|
||||
@@ -182,21 +200,39 @@ impl AdminServiceBuilder {
|
||||
capability_profile: self
|
||||
.capability_profile
|
||||
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
|
||||
outbound_http_policy: self.outbound_http_policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminService {
|
||||
pub async fn export_workspace(
|
||||
pub async fn export_workspace_catalog_snapshot(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<WorkspaceExportResponse, ApiError> {
|
||||
) -> Result<WorkspaceCatalogSnapshotResponse, ApiError> {
|
||||
let workspace = self.get_workspace(workspace_id).await?;
|
||||
let operations = self.list_operations(workspace_id).await?;
|
||||
let agents = self.list_agents(workspace_id).await?;
|
||||
let platform_api_keys = self.registry.list_platform_api_keys(workspace_id).await?;
|
||||
|
||||
Ok(WorkspaceExportResponse {
|
||||
Ok(WorkspaceCatalogSnapshotResponse {
|
||||
kind: "workspace_catalog_snapshot".to_owned(),
|
||||
format_version: "1".to_owned(),
|
||||
restorable: false,
|
||||
included: vec![
|
||||
"workspace_settings".to_owned(),
|
||||
"operation_summaries".to_owned(),
|
||||
"agent_summaries".to_owned(),
|
||||
"platform_api_key_metadata".to_owned(),
|
||||
],
|
||||
excluded: vec![
|
||||
"operation_versions_and_samples".to_owned(),
|
||||
"agent_versions_and_bindings".to_owned(),
|
||||
"secret_metadata_and_values".to_owned(),
|
||||
"secret_values".to_owned(),
|
||||
"invocation_logs_and_usage".to_owned(),
|
||||
"authentication_sessions".to_owned(),
|
||||
],
|
||||
workspace,
|
||||
operations,
|
||||
agents,
|
||||
@@ -227,9 +263,13 @@ impl AdminService {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let auth_profile = self
|
||||
.registry
|
||||
.get_auth_profile(workspace_id, auth_profile_id)
|
||||
let span = Stage::AuthResolve.span();
|
||||
let result = async {
|
||||
let auth_profile = observe_db_query(
|
||||
DbOperation::AuthProfileRead,
|
||||
self.registry
|
||||
.get_auth_profile(workspace_id, auth_profile_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load auth profile",
|
||||
@@ -239,9 +279,20 @@ impl AdminService {
|
||||
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||
})?;
|
||||
|
||||
self.resolve_auth_profile(workspace_id, &auth_profile)
|
||||
.await
|
||||
.map(Some)
|
||||
self.resolve_auth_profile(workspace_id, &auth_profile)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
.instrument(span.clone())
|
||||
.await;
|
||||
match &result {
|
||||
Ok(_) => StageOutcome::Success.record(&span),
|
||||
Err(_) => {
|
||||
StageOutcome::Error.record(&span);
|
||||
ErrorCategory::Configuration.record(&span);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn resolve_auth_profile(
|
||||
@@ -253,40 +304,46 @@ impl AdminService {
|
||||
let used_at = OffsetDateTime::now_utc();
|
||||
|
||||
for secret_id in auth_profile.config.secret_ids() {
|
||||
let secret = self
|
||||
.registry
|
||||
.get_secret(workspace_id, secret_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load secret",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
let version = self
|
||||
.registry
|
||||
.get_current_secret_version(workspace_id, secret_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load current secret version",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
version: secret.secret.current_version,
|
||||
})?;
|
||||
let secret = observe_db_query(
|
||||
DbOperation::SecretRead,
|
||||
self.registry.get_secret(workspace_id, secret_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load secret",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
let version = observe_db_query(
|
||||
DbOperation::SecretRead,
|
||||
self.registry
|
||||
.get_current_secret_version(workspace_id, secret_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load current secret version",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
version: secret.secret.current_version,
|
||||
})?;
|
||||
let plaintext = self.secret_crypto.decrypt(
|
||||
&version.secret_version.key_version,
|
||||
&version.secret_version.ciphertext,
|
||||
)?;
|
||||
self.registry
|
||||
.touch_secret(workspace_id, secret_id, &used_at)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "touch secret",
|
||||
details: error.to_string(),
|
||||
})?;
|
||||
observe_db_query(
|
||||
DbOperation::SecretTouch,
|
||||
self.registry
|
||||
.touch_secret(workspace_id, secret_id, &used_at),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "touch secret",
|
||||
details: error.to_string(),
|
||||
})?;
|
||||
secrets.insert(secret_id.clone(), plaintext);
|
||||
}
|
||||
|
||||
@@ -296,8 +353,11 @@ impl AdminService {
|
||||
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
|
||||
self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
|
||||
validate_protocol_target(payload.protocol, &payload.target)?;
|
||||
self.validate_outbound_target(&payload.target)?;
|
||||
validate_execution_timeout(&payload.execution_config)?;
|
||||
validate_response_cache_policy(&payload.target, &payload.execution_config)?;
|
||||
validate_idempotency_policy(&payload.target, &payload.execution_config)?;
|
||||
validate_approval_policy(&payload.execution_config)?;
|
||||
payload.input_mapping.validate_paths()?;
|
||||
payload.output_mapping.validate_paths()?;
|
||||
Ok(())
|
||||
@@ -306,13 +366,25 @@ impl AdminService {
|
||||
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
|
||||
self.validate_operation_capabilities(operation.protocol, operation.security_level)?;
|
||||
validate_protocol_target(operation.protocol, &operation.target)?;
|
||||
self.validate_outbound_target(&operation.target)?;
|
||||
validate_execution_timeout(&operation.execution_config)?;
|
||||
validate_response_cache_policy(&operation.target, &operation.execution_config)?;
|
||||
validate_idempotency_policy(&operation.target, &operation.execution_config)?;
|
||||
validate_approval_policy(&operation.execution_config)?;
|
||||
operation.input_mapping.validate_paths()?;
|
||||
operation.output_mapping.validate_paths()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_outbound_target(&self, target: &crank_core::Target) -> Result<(), ApiError> {
|
||||
match target {
|
||||
crank_core::Target::Rest(rest) => self
|
||||
.outbound_http_policy
|
||||
.validate_base_url(&rest.base_url)
|
||||
.map_err(|error| ApiError::validation(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_operation_capabilities(
|
||||
&self,
|
||||
protocol: Protocol,
|
||||
@@ -385,7 +457,7 @@ impl AdminService {
|
||||
async fn record_invocation(
|
||||
&self,
|
||||
request: InvocationRecordRequest<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
) -> InvocationHistoryWriteOutcome {
|
||||
let log = InvocationLog {
|
||||
id: InvocationLogId::new(new_prefixed_id("log")),
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
@@ -405,11 +477,75 @@ impl AdminService {
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &log })
|
||||
.await?;
|
||||
let history_span = crank_trace::Stage::HistoryWrite.span();
|
||||
let (outcome, db_span) = async {
|
||||
let db_span = crank_trace::DbOperation::InvocationHistoryWrite.span();
|
||||
let outcome = self
|
||||
.registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &log })
|
||||
.instrument(db_span.clone())
|
||||
.await;
|
||||
(outcome, db_span)
|
||||
}
|
||||
.instrument(history_span.clone())
|
||||
.await;
|
||||
match outcome {
|
||||
InvocationHistoryWriteOutcome::Recorded => {
|
||||
crank_trace::StageOutcome::Success.record(&db_span);
|
||||
crank_trace::StageOutcome::Success.record(&history_span);
|
||||
}
|
||||
InvocationHistoryWriteOutcome::Lost(_) => {
|
||||
crank_trace::StageOutcome::Error.record(&db_span);
|
||||
crank_trace::ErrorCategory::Database.record(&db_span);
|
||||
crank_trace::StageOutcome::Error.record(&history_span);
|
||||
crank_trace::ErrorCategory::History.record(&history_span);
|
||||
}
|
||||
}
|
||||
drop(db_span);
|
||||
drop(history_span);
|
||||
observe_invocation_history_outcome(
|
||||
outcome,
|
||||
request.request_id,
|
||||
request.status,
|
||||
request.source,
|
||||
);
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
fn observe_invocation_history_outcome(
|
||||
outcome: InvocationHistoryWriteOutcome,
|
||||
request_id: Option<&str>,
|
||||
status: crank_core::InvocationStatus,
|
||||
source: InvocationSource,
|
||||
) {
|
||||
let Some(loss) = outcome.loss() else {
|
||||
return;
|
||||
};
|
||||
crank_observability::record_operational_incident(
|
||||
crank_observability::OperationalIncident::InvocationHistoryLost,
|
||||
);
|
||||
tracing::warn!(
|
||||
name: "admin.invocation_history.lost",
|
||||
request_id = request_id.unwrap_or_default(),
|
||||
source = invocation_source_label(source),
|
||||
invocation_status = invocation_status_label(status),
|
||||
error_category = loss.category.as_str(),
|
||||
"invocation history was not recorded"
|
||||
);
|
||||
}
|
||||
|
||||
fn invocation_source_label(source: InvocationSource) -> &'static str {
|
||||
match source {
|
||||
InvocationSource::AdminTestRun => "admin_test_run",
|
||||
InvocationSource::AgentToolCall => "agent_tool_call",
|
||||
}
|
||||
}
|
||||
|
||||
fn invocation_status_label(status: crank_core::InvocationStatus) -> &'static str {
|
||||
match status {
|
||||
crank_core::InvocationStatus::Ok => "ok",
|
||||
crank_core::InvocationStatus::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,9 +611,9 @@ fn new_prefixed_id(prefix: &str) -> String {
|
||||
format!("{prefix}_{}", Uuid::now_v7().simple())
|
||||
}
|
||||
|
||||
fn generate_access_secret(prefix: &str) -> String {
|
||||
fn generate_access_secret(marker: &str) -> String {
|
||||
let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes());
|
||||
format!("{prefix}_{random}")
|
||||
format!("{marker}{random}")
|
||||
}
|
||||
|
||||
fn hash_access_secret(secret: &str) -> String {
|
||||
@@ -516,6 +652,10 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
||||
RuntimeError::ConfirmationRequired { .. } => "confirmation_required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency_unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "idempotency_in_progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "idempotency_conflict",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "idempotency_outcome_unknown",
|
||||
RuntimeError::RestAdapter(_) => "rest_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "adapter_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||
@@ -671,6 +811,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
|
||||
published_at: summary.published_at.map(format_timestamp),
|
||||
operation_count: 0,
|
||||
operation_ids: Vec::new(),
|
||||
tool_selection_policy: Default::default(),
|
||||
key_count: 0,
|
||||
calls_today: 0,
|
||||
mcp_endpoint: String::new(),
|
||||
@@ -733,7 +874,25 @@ fn tool_quality_mapping_rule(rule: &MappingRule) -> ToolQualityMappingRule {
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::items_after_test_module)]
|
||||
mod tests {
|
||||
use super::{validate_profile_display_name, validate_profile_email};
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crank_core::{InvocationSource, InvocationStatus};
|
||||
use crank_observability::{
|
||||
ObservabilityConfig, OperationalIncident, RedactionLimits, ServiceIdentity,
|
||||
operational_incident_total,
|
||||
};
|
||||
use crank_registry::{
|
||||
InvocationHistoryLoss, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
use super::{
|
||||
observe_invocation_history_outcome, validate_profile_display_name, validate_profile_email,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn validates_profile_identity_fields() {
|
||||
@@ -754,6 +913,81 @@ mod tests {
|
||||
assert!(validate_profile_display_name(&"x".repeat(81)).is_err());
|
||||
assert!(validate_profile_email("owner <html>@crank.local").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_bounded_history_loss_incident() {
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = crank_observability::build_subscriber(
|
||||
ObservabilityConfig::new(
|
||||
ServiceIdentity::try_new("admin-api", "test", "test").unwrap(),
|
||||
"info",
|
||||
RedactionLimits::default(),
|
||||
),
|
||||
writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let before = operational_incident_total(OperationalIncident::InvocationHistoryLost);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
|
||||
observe_invocation_history_outcome(
|
||||
InvocationHistoryWriteOutcome::Lost(InvocationHistoryLoss {
|
||||
category: InvocationHistoryLossCategory::InvalidRecord,
|
||||
}),
|
||||
Some("req_admin_dc08"),
|
||||
InvocationStatus::Error,
|
||||
InvocationSource::AgentToolCall,
|
||||
);
|
||||
|
||||
let output = writer.output();
|
||||
assert!(!output.contains("dc08-canary-secret"));
|
||||
let event: Value = output
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.find(|event: &Value| event["event"] == "admin.invocation_history.lost")
|
||||
.unwrap();
|
||||
assert_eq!(event["request_id"], "req_admin_dc08");
|
||||
assert_eq!(event["fields"]["source"], "agent_tool_call");
|
||||
assert_eq!(event["fields"]["invocation_status"], "error");
|
||||
assert_eq!(event["fields"]["error_category"], "invalid_record");
|
||||
assert!(operational_incident_total(OperationalIncident::InvocationHistoryLost) > before);
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_operation_summary(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, UsagePeriod,
|
||||
WorkspaceId,
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool,
|
||||
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
|
||||
};
|
||||
use crank_registry::{
|
||||
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
|
||||
SaveAgentBindingsRequest, UsageBucket, UsageQuery,
|
||||
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
@@ -15,13 +15,93 @@ use tracing::{info, instrument};
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, AgentBindingPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
||||
CreatedAgentResponse, PublishAgentResponse, UpdateAgentPayload, agent_mcp_endpoint,
|
||||
format_timestamp, map_agent_summary_view, new_prefixed_id, today_start_utc,
|
||||
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
||||
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
|
||||
today_start_utc,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
pub async fn preview_tool_search(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: ToolSearchPreviewPayload,
|
||||
) -> Result<Vec<crank_core::ToolSearchMatch>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
if payload.tool_selection_policy.mode != ToolAccessMode::Search {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"tool search preview requires search mode",
|
||||
json!({"field": "tool_selection_policy.mode"}),
|
||||
));
|
||||
}
|
||||
let bindings = payload
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| AgentOperationBinding {
|
||||
operation_id: OperationId::new(binding.operation_id.clone()),
|
||||
operation_version: binding.operation_version,
|
||||
tool_name: binding.tool_name.clone(),
|
||||
tool_title: binding.tool_title.clone(),
|
||||
tool_description_override: binding.tool_description_override.clone(),
|
||||
enabled: binding.enabled,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
validate_tool_selection_policy(&payload.tool_selection_policy, &bindings)?;
|
||||
|
||||
let mut tools = Vec::new();
|
||||
for binding in bindings.iter().filter(|binding| binding.enabled) {
|
||||
let version = self
|
||||
.registry
|
||||
.get_operation_version(
|
||||
workspace_id,
|
||||
&binding.operation_id,
|
||||
binding.operation_version,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("operation {} was not found", binding.operation_id.as_str()),
|
||||
json!({"operation_id": binding.operation_id.as_str()}),
|
||||
)
|
||||
})?;
|
||||
let groups = payload
|
||||
.tool_selection_policy
|
||||
.groups
|
||||
.iter()
|
||||
.filter(|group| {
|
||||
group
|
||||
.tool_names
|
||||
.iter()
|
||||
.any(|name| name == &binding.tool_name)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tools.push(SearchableTool {
|
||||
name: binding.tool_name.clone(),
|
||||
title: binding.tool_title.clone(),
|
||||
description: binding
|
||||
.tool_description_override
|
||||
.clone()
|
||||
.unwrap_or(version.snapshot.tool_description.description),
|
||||
input_schema: serde_json::Value::Null,
|
||||
group_ids: groups.iter().map(|group| group.id.clone()).collect(),
|
||||
group_context: groups
|
||||
.iter()
|
||||
.map(|group| format!("{} {}", group.name, group.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
});
|
||||
}
|
||||
let max_results = payload.tool_selection_policy.search.max_results;
|
||||
Ok(search_tool_catalog(
|
||||
&tools,
|
||||
&payload.query,
|
||||
&payload.group_ids,
|
||||
max_results,
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_agents(
|
||||
&self,
|
||||
@@ -69,6 +149,7 @@ impl AdminService {
|
||||
items.push(AgentSummaryView {
|
||||
operation_count: operation_ids.len(),
|
||||
operation_ids,
|
||||
tool_selection_policy: version.snapshot.tool_selection_policy,
|
||||
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||
mcp_endpoint: agent_mcp_endpoint(
|
||||
@@ -130,6 +211,7 @@ impl AdminService {
|
||||
Ok(AgentSummaryView {
|
||||
operation_count: operation_ids.len(),
|
||||
operation_ids,
|
||||
tool_selection_policy: version.snapshot.tool_selection_policy,
|
||||
key_count,
|
||||
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
|
||||
mcp_endpoint: agent_mcp_endpoint(
|
||||
@@ -214,7 +296,12 @@ impl AdminService {
|
||||
bindings: &[],
|
||||
})
|
||||
.await?;
|
||||
info!(agent_id = %agent_id.as_str(), version = 1, "agent created");
|
||||
info!(
|
||||
name: "admin.agent.created",
|
||||
agent_id = %agent_id.as_str(),
|
||||
version = 1,
|
||||
"agent created"
|
||||
);
|
||||
|
||||
Ok(CreatedAgentResponse {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
@@ -304,9 +391,12 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: Vec<AgentBindingPayload>,
|
||||
payload: AgentCatalogPayload,
|
||||
) -> Result<AgentVersionRecord, ApiError> {
|
||||
let agent = self.get_agent(workspace_id, agent_id).await?;
|
||||
let current_version = self
|
||||
.ensure_editable_agent_version(workspace_id, agent_id)
|
||||
.await?;
|
||||
let (payload, requested_policy) = payload.into_parts();
|
||||
let bindings = payload
|
||||
.into_iter()
|
||||
.map(|binding| AgentOperationBinding {
|
||||
@@ -318,23 +408,63 @@ impl AdminService {
|
||||
enabled: binding.enabled,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let tool_selection_policy = requested_policy
|
||||
.unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone());
|
||||
validate_tool_selection_policy(&tool_selection_policy, &bindings)?;
|
||||
|
||||
self.registry
|
||||
.save_agent_bindings(SaveAgentBindingsRequest {
|
||||
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
agent_version: agent.current_draft_version,
|
||||
agent_version: current_version.version,
|
||||
bindings: &bindings,
|
||||
tool_selection_policy: &tool_selection_policy,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.bindings_saved",
|
||||
agent_id = %agent_id.as_str(),
|
||||
version = agent.current_draft_version,
|
||||
version = current_version.version,
|
||||
binding_count = bindings.len(),
|
||||
"agent bindings saved"
|
||||
);
|
||||
|
||||
self.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
|
||||
self.get_agent_version(workspace_id, agent_id, current_version.version)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_editable_agent_version(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<AgentVersionRecord, ApiError> {
|
||||
let agent = self.get_agent(workspace_id, agent_id).await?;
|
||||
let current = self
|
||||
.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
|
||||
.await?;
|
||||
if agent.latest_published_version != Some(agent.current_draft_version) {
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let draft = AgentVersion {
|
||||
agent_id: agent_id.clone(),
|
||||
version: current.version + 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: current.snapshot.instructions.clone(),
|
||||
tool_selection_policy: current.snapshot.tool_selection_policy.clone(),
|
||||
created_at: now,
|
||||
};
|
||||
self.registry
|
||||
.create_agent_draft_version(CreateAgentDraftVersionRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
version: &draft,
|
||||
bindings: ¤t.bindings,
|
||||
updated_at: &now,
|
||||
})
|
||||
.await?;
|
||||
self.get_agent_version(workspace_id, agent_id, draft.version)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -351,6 +481,10 @@ impl AdminService {
|
||||
let published_bindings = self
|
||||
.published_agent_bindings(workspace_id, &agent_version.bindings)
|
||||
.await?;
|
||||
validate_tool_selection_policy(
|
||||
&agent_version.snapshot.tool_selection_policy,
|
||||
&published_bindings,
|
||||
)?;
|
||||
|
||||
if published_bindings.is_empty() {
|
||||
return Err(ApiError::conflict_with_context(
|
||||
@@ -403,7 +537,12 @@ impl AdminService {
|
||||
published_by: None,
|
||||
})
|
||||
.await?;
|
||||
info!(agent_id = %agent_id.as_str(), version, "agent published");
|
||||
info!(
|
||||
name: "admin.agent.published",
|
||||
agent_id = %agent_id.as_str(),
|
||||
version,
|
||||
"agent published"
|
||||
);
|
||||
|
||||
Ok(PublishAgentResponse {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
@@ -461,7 +600,11 @@ impl AdminService {
|
||||
self.registry
|
||||
.unpublish_agent(workspace_id, agent_id, &updated_at)
|
||||
.await?;
|
||||
info!(agent_id = %agent_id.as_str(), "agent moved to draft");
|
||||
info!(
|
||||
name: "admin.agent.unpublished",
|
||||
agent_id = %agent_id.as_str(),
|
||||
"agent moved to draft"
|
||||
);
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
@@ -481,7 +624,11 @@ impl AdminService {
|
||||
self.registry
|
||||
.archive_agent(workspace_id, agent_id, &updated_at)
|
||||
.await?;
|
||||
info!(agent_id = %agent_id.as_str(), "agent archived");
|
||||
info!(
|
||||
name: "admin.agent.archived",
|
||||
agent_id = %agent_id.as_str(),
|
||||
"agent archived"
|
||||
);
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
@@ -490,3 +637,22 @@ impl AdminService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_tool_selection_policy(
|
||||
policy: &ToolSelectionPolicy,
|
||||
bindings: &[AgentOperationBinding],
|
||||
) -> Result<(), ApiError> {
|
||||
policy
|
||||
.validate_for_tools(
|
||||
bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.enabled)
|
||||
.map(|binding| binding.tool_name.as_str()),
|
||||
)
|
||||
.map_err(|error| {
|
||||
ApiError::validation_with_context(
|
||||
error.to_string(),
|
||||
json!({"field": "tool_selection_policy"}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,16 @@ 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(payload.key_kind.secret_marker());
|
||||
let api_key = PlatformApiKeyRecord {
|
||||
api_key: PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
|
||||
@@ -61,10 +73,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 +124,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(())
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ impl AdminService {
|
||||
)?;
|
||||
let user_id = self
|
||||
.registry
|
||||
.upsert_bootstrap_user(
|
||||
.ensure_bootstrap_user(
|
||||
&self.auth_settings.bootstrap_admin.email,
|
||||
&self.auth_settings.bootstrap_admin.display_name,
|
||||
&password_hash,
|
||||
@@ -227,6 +227,7 @@ impl AdminService {
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
current_session_id: &UserSessionId,
|
||||
payload: ChangePasswordPayload,
|
||||
) -> Result<(), ApiError> {
|
||||
if payload.new_password.len() < 12 {
|
||||
@@ -257,7 +258,11 @@ impl AdminService {
|
||||
let password_hash =
|
||||
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
|
||||
self.registry
|
||||
.update_user_password(user_id, &password_hash)
|
||||
.update_user_password_and_revoke_other_sessions(
|
||||
user_id,
|
||||
current_session_id,
|
||||
&password_hash,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,8 +2,8 @@ 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};
|
||||
@@ -131,6 +131,7 @@ impl AdminService {
|
||||
Ok(()) => Ok(()),
|
||||
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => {
|
||||
tracing::warn!(
|
||||
name: "admin.demo_operation.cleanup_skipped",
|
||||
operation_id = %operation_id.as_str(),
|
||||
"legacy demo operation is still bound to a published agent; leaving it in place"
|
||||
);
|
||||
@@ -162,7 +163,10 @@ impl AdminService {
|
||||
agent_id,
|
||||
PlatformApiKeyPayload {
|
||||
name: name.to_owned(),
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
scopes,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
@@ -269,7 +273,7 @@ impl AdminService {
|
||||
publish: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let summary = self.get_agent(workspace_id, agent_id).await?;
|
||||
self.save_agent_bindings(workspace_id, agent_id, bindings)
|
||||
self.save_agent_bindings(workspace_id, agent_id, bindings.into())
|
||||
.await?;
|
||||
if publish && summary.latest_published_version.is_none() {
|
||||
self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
|
||||
@@ -332,7 +336,7 @@ impl AdminService {
|
||||
}),
|
||||
response_preview: demo_rest_response_sample(),
|
||||
})
|
||||
.await?;
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -345,10 +349,7 @@ fn demo_currency_agent_payload() -> AgentPayload {
|
||||
instructions: json!({
|
||||
"system": "Используй инструменты Frankfurter только для запросов о курсах валют."
|
||||
}),
|
||||
tool_selection_policy: json!({
|
||||
"max_tools": 4,
|
||||
"prefer_tag": ["currency", "exchange-rate"]
|
||||
}),
|
||||
tool_selection_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +385,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -110,6 +110,7 @@ impl AdminService {
|
||||
warnings,
|
||||
};
|
||||
info!(
|
||||
name: "admin.operation.imported",
|
||||
operation_id = %response.operation_id,
|
||||
version = response.version,
|
||||
"operation imported by upsert"
|
||||
@@ -125,6 +126,7 @@ impl AdminService {
|
||||
warnings,
|
||||
};
|
||||
info!(
|
||||
name: "admin.operation.imported",
|
||||
operation_id = %response.operation_id,
|
||||
version = response.version,
|
||||
"operation imported by upsert"
|
||||
|
||||
@@ -8,9 +8,11 @@ use crank_import::rest::{
|
||||
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateImportJobRequest, FinishImportJobRequest, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||
ApplyImportJobRequest, CreateImportJobRequest, ImportConflictMode, ImportJobId, ImportJobKind,
|
||||
ImportJobStatus, ImportOperationDraft,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{info, instrument};
|
||||
|
||||
@@ -50,7 +52,7 @@ impl AdminService {
|
||||
kind: ImportJobKind::OpenApi,
|
||||
source_format: &preview.source.format,
|
||||
source_version: preview.source.version.as_deref(),
|
||||
status: ImportJobStatus::Completed,
|
||||
status: ImportJobStatus::Pending,
|
||||
preview_payload: &preview_payload,
|
||||
created_at: &now,
|
||||
expires_at: &expires_at,
|
||||
@@ -99,9 +101,13 @@ impl AdminService {
|
||||
return Err(ApiError::validation("import job kind is not openapi"));
|
||||
}
|
||||
|
||||
let preview: crank_import::rest::ImportPreview =
|
||||
serde_json::from_value(job.preview_payload.clone())
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
let stored_preview = job
|
||||
.preview_payload
|
||||
.get("preview")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| job.preview_payload.clone());
|
||||
let preview: crank_import::rest::ImportPreview = serde_json::from_value(stored_preview)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
let selected = payload
|
||||
.selected_operation_keys
|
||||
.iter()
|
||||
@@ -120,10 +126,8 @@ impl AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
let mut created = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
let mut findings = Vec::new();
|
||||
let mut created_ids = Vec::new();
|
||||
let mut operations = Vec::new();
|
||||
|
||||
for operation_key in selected {
|
||||
let Some(candidate) = candidates.get(&operation_key) else {
|
||||
@@ -137,44 +141,7 @@ impl AdminService {
|
||||
let mut draft =
|
||||
operation_draft_from_candidate(candidate, payload.server_url.as_deref());
|
||||
attach_import_findings(&mut draft, candidate);
|
||||
if let Some(existing_name) = self
|
||||
.find_operation_by_name(workspace_id, &draft.name)
|
||||
.await?
|
||||
.map(|operation| operation.name)
|
||||
{
|
||||
if payload.conflict_mode == "skip" {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key: candidate.key.clone(),
|
||||
name: draft.name.clone(),
|
||||
reason: "operation with this name already exists".to_owned(),
|
||||
});
|
||||
findings.push(ImportFinding {
|
||||
code: "operation_name_conflict".to_owned(),
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: format!(
|
||||
"Операция {} уже существует и была пропущена.",
|
||||
draft.name
|
||||
),
|
||||
operation_key: Some(candidate.key.clone()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let renamed = self
|
||||
.next_available_operation_name(workspace_id, &draft.name)
|
||||
.await?;
|
||||
findings.push(ImportFinding {
|
||||
code: "operation_name_renamed".to_owned(),
|
||||
severity: ImportFindingSeverity::Info,
|
||||
message: format!(
|
||||
"Операция {existing_name} уже существует, новый черновик создан как {renamed}."
|
||||
),
|
||||
operation_key: Some(candidate.key.clone()),
|
||||
});
|
||||
draft.name = renamed;
|
||||
}
|
||||
|
||||
let payload = OperationPayload {
|
||||
let operation = self.new_operation_snapshot(OperationPayload {
|
||||
name: draft.name.clone(),
|
||||
display_name: draft.display_name.clone(),
|
||||
category: draft.category,
|
||||
@@ -191,32 +158,80 @@ impl AdminService {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
tool_description: draft.tool_description,
|
||||
wizard_state: draft.wizard_state,
|
||||
};
|
||||
let result = self.create_operation(workspace_id, payload).await?;
|
||||
created_ids.push(result.operation_id.clone());
|
||||
created.push(OpenApiImportCreatedOperation {
|
||||
operation_id: result.operation_id,
|
||||
name: draft.name,
|
||||
version: result.version,
|
||||
})?;
|
||||
operations.push(ImportOperationDraft {
|
||||
operation_key: candidate.key.clone(),
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
let finished_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.finish_import_job(FinishImportJobRequest {
|
||||
let application_key = openapi_application_key(&payload)?;
|
||||
let conflict_mode = if payload.conflict_mode == "skip" {
|
||||
ImportConflictMode::Skip
|
||||
} else {
|
||||
ImportConflictMode::Rename
|
||||
};
|
||||
let applied = self
|
||||
.registry
|
||||
.apply_import_job(ApplyImportJobRequest {
|
||||
id: job_id,
|
||||
status: ImportJobStatus::Completed,
|
||||
created_operation_ids: &json!(created_ids),
|
||||
error_text: None,
|
||||
workspace_id,
|
||||
application_key: &application_key,
|
||||
conflict_mode,
|
||||
operations: &operations,
|
||||
finished_at: &finished_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let created = applied
|
||||
.created
|
||||
.iter()
|
||||
.map(|operation| OpenApiImportCreatedOperation {
|
||||
operation_id: operation.operation_id.as_str().to_owned(),
|
||||
name: operation.name.clone(),
|
||||
version: operation.version,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut findings = applied
|
||||
.created
|
||||
.iter()
|
||||
.filter_map(|operation| {
|
||||
operation.renamed_from.as_ref().map(|previous_name| ImportFinding {
|
||||
code: "operation_name_renamed".to_owned(),
|
||||
severity: ImportFindingSeverity::Info,
|
||||
message: format!(
|
||||
"Операция {previous_name} уже существует, новый черновик создан как {}.",
|
||||
operation.name
|
||||
),
|
||||
operation_key: Some(operation.operation_key.clone()),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for operation in applied.skipped {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key: operation.operation_key.clone(),
|
||||
name: operation.name.clone(),
|
||||
reason: "operation with this name already exists".to_owned(),
|
||||
});
|
||||
findings.push(ImportFinding {
|
||||
code: operation.reason,
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: format!(
|
||||
"Операция {} уже существует и была пропущена.",
|
||||
operation.name
|
||||
),
|
||||
operation_key: Some(operation.operation_key),
|
||||
});
|
||||
}
|
||||
info!(
|
||||
name: "admin.openapi_import.completed",
|
||||
created = created.len(),
|
||||
skipped = skipped.len(),
|
||||
"openapi import created drafts"
|
||||
@@ -228,25 +243,21 @@ impl AdminService {
|
||||
findings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_available_operation_name(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
base_name: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
for index in 2.. {
|
||||
let candidate = format!("{base_name}_{index}");
|
||||
if self
|
||||
.find_operation_by_name(workspace_id, &candidate)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
fn openapi_application_key(payload: &OpenApiImportCreatePayload) -> Result<String, ApiError> {
|
||||
let selected_operation_keys = payload
|
||||
.selected_operation_keys
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let canonical = serde_json::to_vec(&json!({
|
||||
"selected_operation_keys": selected_operation_keys,
|
||||
"server_url": payload.server_url.as_deref(),
|
||||
"conflict_mode": payload.conflict_mode.as_str(),
|
||||
}))
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
Ok(format!("{:x}", Sha256::digest(canonical)))
|
||||
}
|
||||
|
||||
fn attach_import_findings(
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
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 {
|
||||
pub async fn cleanup_invocation_logs_before(
|
||||
&self,
|
||||
cutoff: OffsetDateTime,
|
||||
) -> Result<u64, ApiError> {
|
||||
self.registry
|
||||
.delete_invocation_logs_before(cutoff)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_logs(
|
||||
&self,
|
||||
@@ -52,6 +72,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,
|
||||
|
||||
@@ -3,6 +3,8 @@ use serde_json::json;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
const MAX_OPERATION_TIMEOUT_MS: u64 = 300_000;
|
||||
|
||||
pub(super) fn validate_protocol_target(
|
||||
protocol: Protocol,
|
||||
target: &Target,
|
||||
@@ -16,6 +18,18 @@ pub(super) fn validate_protocol_target(
|
||||
Err(ApiError::validation("protocol and target kind must match"))
|
||||
}
|
||||
|
||||
pub(super) fn validate_execution_timeout(
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
if !(1..=MAX_OPERATION_TIMEOUT_MS).contains(&execution_config.timeout_ms) {
|
||||
return Err(ApiError::validation_with_context(
|
||||
format!("operation timeout must be between 1 and {MAX_OPERATION_TIMEOUT_MS} ms"),
|
||||
json!({ "field": "execution_config.timeout_ms" }),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_response_cache_policy(
|
||||
target: &Target,
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
@@ -105,16 +119,54 @@ 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_execution_timeout, validate_idempotency_policy,
|
||||
validate_response_cache_policy,
|
||||
};
|
||||
|
||||
fn cacheable_execution_config() -> ExecutionConfig {
|
||||
ExecutionConfig {
|
||||
@@ -123,6 +175,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 +193,7 @@ mod tests {
|
||||
header_name: Some("Idempotency-Key".to_owned()),
|
||||
}),
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
}
|
||||
@@ -159,6 +213,19 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_excessive_execution_timeouts() {
|
||||
let mut config = cacheable_execution_config();
|
||||
config.timeout_ms = 0;
|
||||
assert!(validate_execution_timeout(&config).is_err());
|
||||
|
||||
config.timeout_ms = 300_001;
|
||||
assert!(validate_execution_timeout(&config).is_err());
|
||||
|
||||
config.timeout_ms = 300_000;
|
||||
assert!(validate_execution_timeout(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_response_cache_for_non_get_rest_operation() {
|
||||
let target = Target::Rest(RestTarget {
|
||||
@@ -238,4 +305,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,6 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: OperationPayload,
|
||||
) -> Result<CreatedOperationResponse, ApiError> {
|
||||
self.validate_operation_payload(&payload)?;
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
|
||||
if self
|
||||
@@ -155,10 +154,36 @@ impl AdminService {
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = self.new_operation_snapshot(payload)?;
|
||||
let operation_id = snapshot.id.clone();
|
||||
|
||||
self.registry
|
||||
.create_operation(workspace_id, &snapshot, None)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.operation.created",
|
||||
operation_id = %operation_id.as_str(),
|
||||
version = 1,
|
||||
"operation created"
|
||||
);
|
||||
|
||||
Ok(CreatedOperationResponse {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: 1,
|
||||
status: OperationStatus::Draft,
|
||||
updated_at: format_timestamp(snapshot.updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn new_operation_snapshot(
|
||||
&self,
|
||||
payload: OperationPayload,
|
||||
) -> Result<RegistryOperation, ApiError> {
|
||||
self.validate_operation_payload(&payload)?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let operation_id = OperationId::new(new_prefixed_id("op"));
|
||||
let snapshot = RegistryOperation {
|
||||
id: operation_id.clone(),
|
||||
Ok(RegistryOperation {
|
||||
id: OperationId::new(new_prefixed_id("op")),
|
||||
name: payload.name,
|
||||
display_name: payload.display_name,
|
||||
category: payload.category,
|
||||
@@ -183,19 +208,6 @@ impl AdminService {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: None,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_operation(workspace_id, &snapshot, None)
|
||||
.await?;
|
||||
info!(operation_id = %operation_id.as_str(), version = 1, "operation created");
|
||||
|
||||
Ok(CreatedOperationResponse {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: 1,
|
||||
status: OperationStatus::Draft,
|
||||
updated_at: format_timestamp(snapshot.updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -279,7 +291,12 @@ impl AdminService {
|
||||
created_by: None,
|
||||
})
|
||||
.await?;
|
||||
info!(operation_id = %operation_id.as_str(), version, "operation version created");
|
||||
info!(
|
||||
name: "admin.operation.version_created",
|
||||
operation_id = %operation_id.as_str(),
|
||||
version,
|
||||
"operation version created"
|
||||
);
|
||||
|
||||
Ok(CreatedOperationResponse {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
@@ -364,7 +381,12 @@ impl AdminService {
|
||||
published_by: None,
|
||||
})
|
||||
.await?;
|
||||
info!(operation_id = %operation_id.as_str(), version, "operation published");
|
||||
info!(
|
||||
name: "admin.operation.published",
|
||||
operation_id = %operation_id.as_str(),
|
||||
version,
|
||||
"operation published"
|
||||
);
|
||||
|
||||
Ok(PublishResponse {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
@@ -431,37 +453,44 @@ impl AdminService {
|
||||
.await?;
|
||||
let runtime = RuntimeOperation::from(record.snapshot.clone());
|
||||
let mode = ExecutionMode::Unary;
|
||||
let request_preview =
|
||||
match build_request_preview(&record.snapshot.input_mapping, &payload.input) {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => {
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
workspace_id,
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Error,
|
||||
status: InvocationStatus::Error,
|
||||
message: "mapping preview failed".to_owned(),
|
||||
status_code: None,
|
||||
error_kind: Some("mapping".to_owned()),
|
||||
duration_ms: 0,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
})
|
||||
.await?;
|
||||
return Ok(TestRunResult {
|
||||
ok: false,
|
||||
mode,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping(
|
||||
error,
|
||||
))],
|
||||
});
|
||||
}
|
||||
};
|
||||
let preview_span = crank_trace::Stage::RuntimeArgumentsMap.span();
|
||||
let preview_result = preview_span
|
||||
.in_scope(|| build_request_preview(&record.snapshot.input_mapping, &payload.input));
|
||||
let request_preview = match preview_result {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => {
|
||||
crank_trace::StageOutcome::Error.record(&preview_span);
|
||||
crank_trace::ErrorCategory::Mapping.record(&preview_span);
|
||||
drop(preview_span);
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
workspace_id,
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Error,
|
||||
status: InvocationStatus::Error,
|
||||
message: "mapping preview failed".to_owned(),
|
||||
status_code: None,
|
||||
error_kind: Some("mapping".to_owned()),
|
||||
duration_ms: 0,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
})
|
||||
.await;
|
||||
return Ok(TestRunResult {
|
||||
ok: false,
|
||||
mode,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping(
|
||||
error,
|
||||
))],
|
||||
});
|
||||
}
|
||||
};
|
||||
crank_trace::StageOutcome::Success.record(&preview_span);
|
||||
drop(preview_span);
|
||||
|
||||
let resolved_auth = self
|
||||
.resolve_operation_auth(workspace_id, &runtime.execution_config)
|
||||
@@ -497,7 +526,7 @@ impl AdminService {
|
||||
request_preview: request_preview.clone(),
|
||||
response_preview: response_preview.clone(),
|
||||
})
|
||||
.await?;
|
||||
.await;
|
||||
Ok(TestRunResult {
|
||||
ok: true,
|
||||
mode,
|
||||
@@ -524,7 +553,7 @@ impl AdminService {
|
||||
request_preview: request_preview.clone(),
|
||||
response_preview: Value::Null,
|
||||
})
|
||||
.await?;
|
||||
.await;
|
||||
Ok(TestRunResult {
|
||||
ok: false,
|
||||
mode,
|
||||
|
||||
@@ -50,6 +50,7 @@ impl AdminService {
|
||||
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.sample.saved",
|
||||
operation_id = %operation_id.as_str(),
|
||||
sample_id = %metadata.id.as_str(),
|
||||
version,
|
||||
@@ -119,7 +120,11 @@ impl AdminService {
|
||||
input_mapping,
|
||||
output_mapping,
|
||||
};
|
||||
info!(operation_id = %operation_id.as_str(), "draft generated from samples");
|
||||
info!(
|
||||
name: "admin.operation_draft.generated",
|
||||
operation_id = %operation_id.as_str(),
|
||||
"draft generated from samples"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,11 @@ impl AdminService {
|
||||
created_by,
|
||||
})
|
||||
.await?;
|
||||
info!(secret_id = %secret.id.as_str(), "secret created");
|
||||
info!(
|
||||
name: "admin.secret.created",
|
||||
secret_id = %secret.id.as_str(),
|
||||
"secret created"
|
||||
);
|
||||
|
||||
Ok(secret)
|
||||
}
|
||||
@@ -126,7 +130,11 @@ impl AdminService {
|
||||
created_by,
|
||||
})
|
||||
.await?;
|
||||
info!(secret_id = %secret_id.as_str(), "secret rotated");
|
||||
info!(
|
||||
name: "admin.secret.rotated",
|
||||
secret_id = %secret_id.as_str(),
|
||||
"secret rotated"
|
||||
);
|
||||
|
||||
self.get_secret(workspace_id, secret_id).await
|
||||
}
|
||||
@@ -152,7 +160,11 @@ impl AdminService {
|
||||
.into());
|
||||
}
|
||||
self.registry.delete_secret(workspace_id, secret_id).await?;
|
||||
info!(secret_id = %secret_id.as_str(), "secret deleted");
|
||||
info!(
|
||||
name: "admin.secret.deleted",
|
||||
secret_id = %secret_id.as_str(),
|
||||
"secret deleted"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -201,7 +213,11 @@ impl AdminService {
|
||||
profile: &profile,
|
||||
})
|
||||
.await?;
|
||||
info!(auth_profile_id = %profile.id.as_str(), "auth profile created");
|
||||
info!(
|
||||
name: "admin.auth_profile.created",
|
||||
auth_profile_id = %profile.id.as_str(),
|
||||
"auth profile created"
|
||||
);
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,11 @@ impl AdminService {
|
||||
upstream: &upstream,
|
||||
})
|
||||
.await?;
|
||||
info!(upstream_id = %upstream.id.as_str(), "workspace upstream saved");
|
||||
info!(
|
||||
name: "admin.upstream.saved",
|
||||
upstream_id = %upstream.id.as_str(),
|
||||
"workspace upstream saved"
|
||||
);
|
||||
|
||||
Ok(upstream)
|
||||
}
|
||||
|
||||
@@ -5,4 +5,10 @@ use crank_runtime::RequestRateLimiter;
|
||||
pub struct AppState {
|
||||
pub service: AdminService,
|
||||
pub api_rate_limiter: RequestRateLimiter,
|
||||
/// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification.
|
||||
///
|
||||
/// Only enable when the service sits behind a trusted reverse proxy that
|
||||
/// overwrites these headers (e.g. the bundled nginx). When disabled the
|
||||
/// real TCP peer address is used, which a client cannot spoof.
|
||||
pub trust_forwarded_headers: bool,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::{io, sync::Mutex};
|
||||
|
||||
use axum::{Json, Router, extract::State, routing::post};
|
||||
use crank_core::{OperationId, WorkspaceId};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::{net::TcpListener, sync::Notify};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn preserves_external_success_when_invocation_history_is_lost() {
|
||||
let registry = test_registry().await;
|
||||
let registry_for_failure = registry.clone();
|
||||
let storage_root = test_storage_root("observability_history_loss");
|
||||
let upstream = spawn_blocking_upstream_server().await;
|
||||
let log_writer = SharedLogWriter::default();
|
||||
let subscriber = crank_observability::build_subscriber(
|
||||
crank_observability::ObservabilityConfig::new(
|
||||
crank_observability::ServiceIdentity::try_new("admin-api", "test", "test").unwrap(),
|
||||
"info",
|
||||
crank_observability::RedactionLimits::default(),
|
||||
),
|
||||
log_writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream.base_url,
|
||||
"crm_history_loss",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
let request_client = client.clone();
|
||||
let request_url = format!("{base_url}/operations/{operation_id}/test-runs");
|
||||
let before = crank_observability::operational_incident_total(
|
||||
crank_observability::OperationalIncident::InvocationHistoryLost,
|
||||
);
|
||||
|
||||
let request = tokio::spawn(async move {
|
||||
request_client
|
||||
.post(request_url)
|
||||
.header("x-request-id", "req_dc08_admin")
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "dc08-canary-secret@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
upstream.started.notified().await;
|
||||
registry_for_failure
|
||||
.delete_operation(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&OperationId::new(operation_id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
upstream.release.notify_one();
|
||||
|
||||
let response = request.await.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_dc08_admin"
|
||||
);
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(body["ok"], true);
|
||||
assert_eq!(body["response_preview"]["id"], "lead_123");
|
||||
assert_eq!(upstream.calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
crank_observability::operational_incident_total(
|
||||
crank_observability::OperationalIncident::InvocationHistoryLost
|
||||
) > before
|
||||
);
|
||||
|
||||
let logs = client
|
||||
.get(format!("{base_url}/logs?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs["items"].as_array().unwrap().is_empty());
|
||||
|
||||
let output = log_writer.output();
|
||||
assert!(!output.contains("dc08-canary-secret"));
|
||||
let incident = output
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.find(|event| event["event"] == "admin.invocation_history.lost")
|
||||
.expect("DC-08 incident");
|
||||
assert_eq!(incident["request_id"], "req_dc08_admin");
|
||||
assert_eq!(incident["fields"]["source"], "admin_test_run");
|
||||
}
|
||||
|
||||
struct BlockingUpstream {
|
||||
base_url: String,
|
||||
started: Arc<Notify>,
|
||||
release: Arc<Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BlockingUpstreamState {
|
||||
started: Arc<Notify>,
|
||||
release: Arc<Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
async fn spawn_blocking_upstream_server() -> BlockingUpstream {
|
||||
let state = BlockingUpstreamState {
|
||||
started: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/crm/leads", post(blocking_create_lead))
|
||||
.with_state(state.clone());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
BlockingUpstream {
|
||||
base_url: format!("http://{address}"),
|
||||
started: state.started,
|
||||
release: state.release,
|
||||
calls: state.calls,
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocking_create_lead(
|
||||
State(state): State<BlockingUpstreamState>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
state.calls.fetch_add(1, Ordering::SeqCst);
|
||||
state.started.notify_one();
|
||||
state.release.notified().await;
|
||||
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"status": "created",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -109,15 +109,19 @@ async fn rejects_rapid_login_requests_with_429() {
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
),
|
||||
trust_forwarded_headers: false,
|
||||
});
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
@@ -104,6 +104,7 @@ pub(super) fn build_test_app(
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
),
|
||||
trust_forwarded_headers: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,13 +114,16 @@ pub(super) fn test_service(
|
||||
auth_settings: AuthSettings,
|
||||
secret_crypto: SecretCrypto,
|
||||
) -> AdminService {
|
||||
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
|
||||
AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
auth_settings,
|
||||
secret_crypto,
|
||||
crank_runtime::RuntimeExecutor::new(),
|
||||
runtime,
|
||||
)
|
||||
.with_outbound_http_policy(outbound_policy)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -297,6 +301,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(),
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::net::TcpListener;
|
||||
use uuid::Version;
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
@@ -153,6 +154,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()
|
||||
@@ -164,6 +166,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
|
||||
@@ -192,14 +219,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);
|
||||
@@ -346,6 +386,30 @@ async fn exports_single_workspace_but_rejects_access_lifecycle() {
|
||||
exported["workspace"]["workspace"]["id"],
|
||||
DEFAULT_WORKSPACE_ID
|
||||
);
|
||||
assert_eq!(exported["kind"], "workspace_catalog_snapshot");
|
||||
assert_eq!(exported["format_version"], "1");
|
||||
assert_eq!(exported["restorable"], false);
|
||||
assert_eq!(
|
||||
exported["included"],
|
||||
json!([
|
||||
"workspace_settings",
|
||||
"operation_summaries",
|
||||
"agent_summaries",
|
||||
"platform_api_key_metadata"
|
||||
])
|
||||
);
|
||||
assert!(
|
||||
exported["excluded"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("secret_values"))
|
||||
);
|
||||
assert!(
|
||||
exported["excluded"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("invocation_logs_and_usage"))
|
||||
);
|
||||
assert!(exported.get("memberships").is_none());
|
||||
assert!(exported.get("invitations").is_none());
|
||||
|
||||
@@ -425,7 +489,7 @@ async fn seeds_demo_assets_for_live_ui() {
|
||||
display_name: "Legacy Smoke Agent".to_owned(),
|
||||
description: "Keeps a legacy smoke operation published".to_owned(),
|
||||
instructions: json!({}),
|
||||
tool_selection_policy: json!({}),
|
||||
tool_selection_policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -442,7 +506,8 @@ async fn seeds_demo_assets_for_live_ui() {
|
||||
tool_title: "Legacy health smoke".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
}],
|
||||
}]
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -534,6 +599,25 @@ async fn updates_profile_and_changes_password() {
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let client = authorized_client(&base_url).await;
|
||||
let second_client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
let second_login = second_client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
"password": TEST_AUTH_PASSWORD,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let second_login_status = second_login.status();
|
||||
let second_login_body = second_login.text().await.unwrap();
|
||||
assert!(
|
||||
second_login_status.is_success(),
|
||||
"second login failed with {second_login_status}: {second_login_body}"
|
||||
);
|
||||
|
||||
let profile = assert_success_json(
|
||||
client
|
||||
@@ -575,6 +659,21 @@ async fn updates_profile_and_changes_password() {
|
||||
.status();
|
||||
assert_eq!(password_status, reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let current_session_status = client
|
||||
.get(format!("{root_url}/api/auth/profile"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
let other_session_status = second_client
|
||||
.get(format!("{root_url}/api/auth/profile"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(current_session_status, reqwest::StatusCode::OK);
|
||||
assert_eq!(other_session_status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let relogin_client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
@@ -815,7 +914,10 @@ async fn generates_request_id_for_test_run_invocations() {
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
assert!(!request_id.is_empty());
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(&request_id).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
response.error_for_status().unwrap();
|
||||
|
||||
let logs = client
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload};
|
||||
use crank_core::WorkspaceId;
|
||||
use crank_registry::ImportJobStatus;
|
||||
use serial_test::serial;
|
||||
|
||||
use super::common::{
|
||||
@@ -42,7 +43,7 @@ paths:
|
||||
async fn previews_openapi_and_creates_draft_operations() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry,
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
@@ -64,6 +65,12 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
preview.preview.groups[0].operations[0].suggested_name,
|
||||
"latest_rates"
|
||||
);
|
||||
let preview_job = registry
|
||||
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(preview_job.status, ImportJobStatus::Pending);
|
||||
|
||||
let created = service
|
||||
.create_openapi_import(
|
||||
@@ -112,10 +119,19 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
.any(|finding| finding.code == "openapi_import.weak_tool_description")
|
||||
);
|
||||
|
||||
let skip_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let skipped = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
&skip_preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
@@ -130,10 +146,19 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
assert_eq!(skipped.skipped[0].name, "latest_rates");
|
||||
assert_eq!(skipped.findings[0].code, "operation_name_conflict");
|
||||
|
||||
let rename_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let renamed = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
&rename_preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
@@ -147,3 +172,63 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
assert_eq!(renamed.created[0].name, "latest_rates_2");
|
||||
assert_eq!(renamed.findings[0].code, "operation_name_renamed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn concurrent_openapi_import_replays_the_same_atomic_result() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry,
|
||||
test_storage_root("openapi_import_replay"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id = preview.job_id.as_str().into();
|
||||
let payload = OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "rename".to_owned(),
|
||||
};
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
service.create_openapi_import(&workspace_id, &job_id, payload.clone()),
|
||||
service.create_openapi_import(&workspace_id, &job_id, payload),
|
||||
);
|
||||
let first = first.unwrap();
|
||||
let second = second.unwrap();
|
||||
|
||||
assert_eq!(first.created.len(), 1);
|
||||
assert_eq!(second.created.len(), 1);
|
||||
assert_eq!(
|
||||
first.created[0].operation_id,
|
||||
second.created[0].operation_id
|
||||
);
|
||||
assert_eq!(first.created[0].name, second.created[0].name);
|
||||
assert_eq!(
|
||||
service.list_operations(&workspace_id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
|
||||
let conflicting_replay = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(conflicting_replay.is_err());
|
||||
}
|
||||
|
||||
@@ -398,6 +398,116 @@ async fn creates_binds_and_publishes_agent() {
|
||||
assert_eq!(published["published_version"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn saves_and_previews_versioned_agent_tool_search_policy() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_tool_search");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"finance_create_invoice",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({"version": 1}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "finance-agent",
|
||||
"display_name": "Finance Agent",
|
||||
"description": "Finance workflows",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
|
||||
let catalog = json!({
|
||||
"bindings": [{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "finance_create_invoice",
|
||||
"tool_title": "Create Lead",
|
||||
"tool_description_override": "Creates an invoice for a customer",
|
||||
"enabled": true
|
||||
}],
|
||||
"tool_selection_policy": {
|
||||
"mode": "search",
|
||||
"groups": [{
|
||||
"id": "finance",
|
||||
"name": "Finance",
|
||||
"description": "Invoices and payments",
|
||||
"tool_names": ["finance_create_invoice"]
|
||||
}],
|
||||
"search": {"max_results": 5}
|
||||
}
|
||||
});
|
||||
let saved = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&catalog)
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(saved["snapshot"]["tool_selection_policy"]["mode"], "search");
|
||||
|
||||
let preview = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/tool-search/preview"))
|
||||
.json(&json!({
|
||||
"query": "create invoice",
|
||||
"group_ids": ["finance"],
|
||||
"bindings": catalog["bindings"],
|
||||
"tool_selection_policy": catalog["tool_selection_policy"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
preview["items"][0]["tool"]["name"],
|
||||
"finance_create_invoice"
|
||||
);
|
||||
|
||||
let published = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.json(&json!({"version": 1}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(published["published_version"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use admin_api::request_context::{REQUEST_ID_HEADER, apply_request_context};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{HeaderMap, HeaderValue, Request, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use crank_observability::{
|
||||
ObservabilityConfig, RedactionLimits, ServiceIdentity, inject_current_trace_context,
|
||||
};
|
||||
use opentelemetry::{global, trace::TracerProvider as _};
|
||||
use opentelemetry_sdk::{propagation::TraceContextPropagator, trace::SdkTracerProvider};
|
||||
use tower::ServiceExt;
|
||||
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt};
|
||||
use uuid::Version;
|
||||
|
||||
static TRACING_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn logs_request_completion_and_rejects_untrusted_values() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = crank_observability::build_subscriber(
|
||||
ObservabilityConfig::new(
|
||||
ServiceIdentity::try_new("admin-api", "test", "test").unwrap(),
|
||||
"info",
|
||||
RedactionLimits::default(),
|
||||
),
|
||||
writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _dispatch_guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let app = probe_app();
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/probe")
|
||||
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"req_admin_trace_123"
|
||||
);
|
||||
let event: serde_json::Value = writer
|
||||
.output()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.find(|event: &serde_json::Value| event["event"] == "admin.request.completed")
|
||||
.unwrap();
|
||||
assert_eq!(event["request_id"], "req_admin_trace_123");
|
||||
assert_eq!(event["fields"]["status"], 200);
|
||||
|
||||
let invalid_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/probe")
|
||||
.header(REQUEST_ID_HEADER.as_str(), "bad,value")
|
||||
.header("traceparent", "canary-invalid-traceparent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let generated = invalid_response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
assert!(!writer.output().contains("canary-invalid-traceparent"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn covers_valid_invalid_and_absent_traceparent() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
let provider = SdkTracerProvider::builder().build();
|
||||
let tracer = provider.tracer("admin-request-context-test");
|
||||
let subscriber =
|
||||
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _dispatch_guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let app = trace_probe_app();
|
||||
|
||||
let valid = observed_trace_id(
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/trace")
|
||||
.header(
|
||||
"traceparent",
|
||||
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
)
|
||||
.header(REQUEST_ID_HEADER.as_str(), "request-id-is-separate")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let invalid = observed_trace_id(
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/trace")
|
||||
.header("traceparent", "canary-invalid-traceparent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let absent = observed_trace_id(
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.uri("/trace")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(valid, "0af7651916cd43dd8448eb211c80319c");
|
||||
assert_ne!(invalid, valid);
|
||||
assert_ne!(absent, valid);
|
||||
assert_ne!(invalid, absent);
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn replaces_multiple_request_id_headers_with_one_uuid_v7() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
let dispatch = tracing::Dispatch::new(tracing_subscriber::registry());
|
||||
let _dispatch_guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let mut request = Request::builder()
|
||||
.uri("/probe")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request.headers_mut().append(
|
||||
REQUEST_ID_HEADER,
|
||||
HeaderValue::from_static("first-request-id"),
|
||||
);
|
||||
request.headers_mut().append(
|
||||
REQUEST_ID_HEADER,
|
||||
HeaderValue::from_static("second-request-id"),
|
||||
);
|
||||
|
||||
let response = probe_app().oneshot(request).await.unwrap();
|
||||
let generated = response.headers()[REQUEST_ID_HEADER].to_str().unwrap();
|
||||
|
||||
assert_ne!(generated, "first-request-id");
|
||||
assert_ne!(generated, "second-request-id");
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
}
|
||||
|
||||
fn probe_app() -> Router {
|
||||
Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(apply_request_context))
|
||||
}
|
||||
|
||||
fn trace_probe_app() -> Router {
|
||||
Router::new()
|
||||
.route("/trace", get(observed_traceparent))
|
||||
.layer(axum::middleware::from_fn(apply_request_context))
|
||||
}
|
||||
|
||||
async fn observed_traceparent() -> HeaderMap {
|
||||
let mut trace_headers = HeaderMap::new();
|
||||
inject_current_trace_context(&mut trace_headers);
|
||||
let mut response_headers = HeaderMap::new();
|
||||
if let Some(traceparent) = trace_headers.remove("traceparent") {
|
||||
response_headers.insert("x-observed-traceparent", traceparent);
|
||||
}
|
||||
response_headers
|
||||
}
|
||||
|
||||
fn observed_trace_id(response: axum::response::Response) -> String {
|
||||
let traceparent = response.headers()["x-observed-traceparent"]
|
||||
.to_str()
|
||||
.unwrap();
|
||||
traceparent[3..35].to_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#[path = "integration/request_context.rs"]
|
||||
mod request_context;
|
||||
@@ -3,6 +3,7 @@ name = "mcp-server"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -15,10 +16,12 @@ axum.workspace = true
|
||||
base64.workspace = true
|
||||
crank-community-mcp = { path = "../../crates/crank-community-mcp" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-observability = { path = "../../crates/crank-observability" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
futures-util = "0.3"
|
||||
metrics.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
@@ -34,4 +37,10 @@ uuid.workspace = true
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
crank-test-support = { path = "../../crates/crank-test-support" }
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry-proto.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
prost.workspace = true
|
||||
reqwest.workspace = true
|
||||
tower.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+73
-23
@@ -1,25 +1,53 @@
|
||||
use std::{env, net::SocketAddr, time::Duration};
|
||||
|
||||
use crank_community_mcp::{
|
||||
auth::CommunityMachineCredentialVerifier, build_app, session::PostgresTransportSessionStore,
|
||||
auth::CommunityMachineCredentialVerifier, build_app_with_background_workers_and_limits,
|
||||
session::PostgresTransportSessionStore,
|
||||
};
|
||||
use crank_observability::{
|
||||
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
|
||||
capture_critical_error,
|
||||
};
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto, community_default,
|
||||
RuntimeLimits, SecretCrypto,
|
||||
};
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use sqlx::{PgPool, postgres::PgConnectOptions};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
env::var("CRANK_LOG_LEVEL")
|
||||
.unwrap_or_else(|_| "mcp_server=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
let observability = crank_observability::init(ObservabilityConfig::from_env(
|
||||
"mcp-server",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"mcp_server=info,tower_http=info",
|
||||
)?)?;
|
||||
let mut startup_completed = false;
|
||||
let result = run(&observability, &mut startup_completed).await;
|
||||
if result.is_err() {
|
||||
capture_critical_error(if startup_completed {
|
||||
CriticalErrorCategory::Internal
|
||||
} else {
|
||||
CriticalErrorCategory::Startup
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(
|
||||
observability: &ObservabilityLifecycle,
|
||||
startup_completed: &mut bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let metrics_config =
|
||||
MetricsConfig::from_env("CRANK_MCP_METRICS_BIND", "127.0.0.1:9465".parse()?)?;
|
||||
let metrics_enabled = metrics_config.enabled();
|
||||
let metrics_server = if metrics_config.enabled() {
|
||||
Some(observability.metrics_surface(metrics_config).bind().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let bind_addr = env::var("CRANK_MCP_BIND").unwrap_or_else(|_| "0.0.0.0:3002".into());
|
||||
let base_url = env::var("CRANK_BASE_URL").ok();
|
||||
@@ -35,23 +63,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||
let api_rate_limit = mcp_api_rate_limit_config_from_env()?;
|
||||
let database_options = database_options_from_env()?;
|
||||
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||
database_options.clone(),
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
let session_store = PostgresTransportSessionStore::connect_with_options_and_pool_config(
|
||||
database_options,
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
let registry =
|
||||
PostgresRegistry::connect_with_options_and_pool_config(database_options, pool_config)
|
||||
.await?;
|
||||
if metrics_enabled {
|
||||
spawn_postgres_pool_metrics(registry.pool().clone());
|
||||
}
|
||||
let session_store = PostgresTransportSessionStore::from_pool(registry.pool().clone()).await?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let runtime = community_default()
|
||||
let runtime = crank_runtime::community_from_env()?
|
||||
.with_limits(runtime_limits)
|
||||
.with_response_cache(cache_stores.response.clone())
|
||||
.with_coordination_store(cache_stores.coordination.clone())
|
||||
.build();
|
||||
let app = build_app(
|
||||
let app = build_app_with_background_workers_and_limits(
|
||||
registry,
|
||||
refresh_interval,
|
||||
base_url,
|
||||
@@ -65,11 +90,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
cache_stores.coordination.clone(),
|
||||
std::sync::Arc::new(session_store),
|
||||
std::sync::Arc::new(CommunityMachineCredentialVerifier),
|
||||
runtime_limits.max_concurrent_sessions,
|
||||
);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
|
||||
info!(
|
||||
name: "mcp.postgres_pool.configured",
|
||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||
runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions,
|
||||
mcp_rate_limit_rps = api_rate_limit.requests_per_second,
|
||||
mcp_rate_limit_burst = api_rate_limit.burst,
|
||||
cache_backend = %cache_config.backend,
|
||||
@@ -80,9 +108,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||
"postgres pool configured"
|
||||
);
|
||||
info!("mcp-server listening on {}", socket_addr);
|
||||
info!(
|
||||
name: "mcp.server.listening",
|
||||
bind_address = %socket_addr,
|
||||
"mcp-server listening"
|
||||
);
|
||||
*startup_completed = true;
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
if let Some(metrics_server) = metrics_server {
|
||||
tokio::select! {
|
||||
result = axum::serve(listener, app) => result?,
|
||||
result = metrics_server.serve() => result?,
|
||||
}
|
||||
} else {
|
||||
axum::serve(listener, app).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -122,3 +162,13 @@ fn mcp_api_rate_limit_config_from_env() -> Result<RequestRateLimitConfig, Box<dy
|
||||
|
||||
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
|
||||
}
|
||||
|
||||
fn spawn_postgres_pool_metrics(pool: PgPool) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
crank_observability::record_db_pool_connections(pool.size(), pool.num_idle());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{PlatformApiKeyScope, WorkspaceId};
|
||||
use crank_observability::{
|
||||
ObservabilityConfig, OperationalIncident, RedactionLimits, ServiceIdentity,
|
||||
operational_incident_total,
|
||||
};
|
||||
use crank_registry::{ListInvocationLogsQuery, PublishRequest};
|
||||
use serde_json::{Value, json};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
const CANARY_SECRET: &str = "dc08-canary-secret";
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_mcp_result_when_postgres_rejects_invocation_history() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_calls = Arc::new(AtomicUsize::new(0));
|
||||
let upstream_base_url = spawn_counted_upstream(Arc::clone(&upstream_calls)).await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_dc08");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-dc08").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-dc08",
|
||||
"mcp-dc08",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
install_history_failure_trigger(®istry).await;
|
||||
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = crank_observability::build_subscriber(
|
||||
ObservabilityConfig::new(
|
||||
ServiceIdentity::try_new("mcp-server", "test", "test").unwrap(),
|
||||
"info",
|
||||
RedactionLimits::default(),
|
||||
),
|
||||
writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
||||
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-dc08");
|
||||
let session_id = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
let before = operational_incident_total(OperationalIncident::InvocationHistoryLost);
|
||||
|
||||
let result = post_jsonrpc_response(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session_id),
|
||||
Some("req_mcp_dc08"),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_dc08",
|
||||
"arguments": {
|
||||
"email": format!("{CANARY_SECRET}@example.com")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["result"]["structuredContent"],
|
||||
json!({"id": "lead_123"})
|
||||
);
|
||||
assert_eq!(result["result"]["isError"], false);
|
||||
assert_eq!(upstream_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(operational_incident_total(OperationalIncident::InvocationHistoryLost) > before);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_empty());
|
||||
|
||||
let output = writer.output();
|
||||
assert!(!output.contains(CANARY_SECRET));
|
||||
let incidents = output
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter(|event| event["event"] == "mcp.invocation_history.lost")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(incidents.len(), 1);
|
||||
assert_eq!(incidents[0]["request_id"], "req_mcp_dc08");
|
||||
assert_eq!(incidents[0]["fields"]["source"], "agent_tool_call");
|
||||
assert_eq!(incidents[0]["fields"]["invocation_status"], "ok");
|
||||
}
|
||||
|
||||
async fn install_history_failure_trigger(registry: &crank_registry::PostgresRegistry) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
create function fail_dc08_invocation_history() returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if new.request_id = 'req_mcp_dc08' then
|
||||
raise exception 'forced DC-08 invocation history failure';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$
|
||||
"#,
|
||||
)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
r#"
|
||||
create trigger fail_dc08_invocation_history
|
||||
before insert on invocation_logs
|
||||
for each row execute function fail_dc08_invocation_history()
|
||||
"#,
|
||||
)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn spawn_counted_upstream(calls: Arc<AtomicUsize>) -> String {
|
||||
let app = Router::new().route(
|
||||
"/crm/leads",
|
||||
post(move |Json(payload): Json<Value>| {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
#[path = "integration/execution_stages.rs"]
|
||||
mod execution_stages;
|
||||
@@ -1,5 +1,6 @@
|
||||
mod integration {
|
||||
mod catalog_access;
|
||||
mod common;
|
||||
mod tool_search;
|
||||
mod transport_protocol;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -136,7 +142,10 @@ fn build_test_app_with_store(
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
RuntimeExecutor::new(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(rate_limit_config),
|
||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
sessions,
|
||||
@@ -476,6 +485,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(®istry, "sales-approval-key", vec![]).await;
|
||||
let api_key =
|
||||
create_approval_platform_api_key(®istry, "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,872 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_key_lists_and_decides_pending_requests() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_human_approval");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-human-approval",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_mcp_01"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-human-approval"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "ada@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-human-approval", "approval-http").await;
|
||||
let mcp_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-human-approval",
|
||||
"mcp-human-approval",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let approvals_url = format!(
|
||||
"{}/approvals",
|
||||
agent_mcp_url(&base_url, "sales-human-approval")
|
||||
);
|
||||
|
||||
let rejected = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {mcp_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let pending = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.status(), reqwest::StatusCode::OK);
|
||||
let pending_body = pending.json::<Value>().await.unwrap();
|
||||
assert_eq!(pending_body["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
pending_body["items"][0]["approval"]["request_payload"],
|
||||
json!({"email": "ada@example.com"})
|
||||
);
|
||||
|
||||
let approve_url = format!(
|
||||
"{}/approvals/{}/approve",
|
||||
agent_mcp_url(&base_url, "sales-human-approval"),
|
||||
approval.id
|
||||
);
|
||||
let approved = client
|
||||
.post(&approve_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.header("x-request-id", "req_approval_execute_123")
|
||||
.json(&json!({ "approve": "yes", "note": "confirmed by test" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(approved.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
approved.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_approval_execute_123"
|
||||
);
|
||||
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);
|
||||
assert_eq!(
|
||||
logs[0].log.request_id.as_deref(),
|
||||
Some("req_approval_execute_123")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_key_denies_without_executing_upstream() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_human_deny");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-human-deny",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_mcp_deny_01"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-human-deny"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "deny@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-human-deny", "approval-deny-http").await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let deny_url = format!(
|
||||
"{}/approvals/{}/deny",
|
||||
agent_mcp_url(&base_url, "sales-human-deny"),
|
||||
approval.id
|
||||
);
|
||||
|
||||
let denied = client
|
||||
.post(&deny_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.json(&json!({ "approve": "no", "note": "rejected by test" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(denied.status(), reqwest::StatusCode::OK);
|
||||
let denied_body = denied.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
denied_body["approval"]["status"],
|
||||
Value::String("denied".to_owned())
|
||||
);
|
||||
|
||||
let repeated_deny = client
|
||||
.post(&deny_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.json(&json!({ "approve": "no", "note": "duplicate rejection" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repeated_deny.status(), reqwest::StatusCode::OK);
|
||||
let repeated_body = repeated_deny.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
repeated_body["approval"]["status"],
|
||||
Value::String("denied".to_owned())
|
||||
);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: Some(&test_agent_id("sales-human-deny")),
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_key_expires_without_executing_upstream() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_human_expired");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-human-expired",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_mcp_expired_01"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-human-expired"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "expired@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: OffsetDateTime::now_utc() - time::Duration::minutes(10),
|
||||
expires_at: OffsetDateTime::now_utc() - time::Duration::minutes(5),
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-human-expired", "approval-expired-http")
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let approve_url = format!(
|
||||
"{}/approvals/{}/approve",
|
||||
agent_mcp_url(&base_url, "sales-human-expired"),
|
||||
approval.id
|
||||
);
|
||||
|
||||
let expired = client
|
||||
.post(&approve_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.json(&json!({ "approve": "yes", "note": "too late" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(expired.status(), reqwest::StatusCode::OK);
|
||||
let expired_body = expired.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
expired_body["approval"]["status"],
|
||||
Value::String("expired".to_owned())
|
||||
);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: Some(&test_agent_id("sales-human-expired")),
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_call_with_approval_policy_creates_pending_request() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval");
|
||||
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Custom,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: None,
|
||||
});
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-gated").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-gated",
|
||||
"mcp-gated",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-gated");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let tool_call = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_requires_human_approval",
|
||||
"arguments": {
|
||||
"email": "ada@example.com"
|
||||
}
|
||||
}
|
||||
});
|
||||
let tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
tool_call.clone(),
|
||||
)
|
||||
.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 repeated_tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
tool_call,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
repeated_tool_result["result"]["structuredContent"]["approval_id"], approval_id,
|
||||
"deduplicated tools/call must return the persisted approval id",
|
||||
);
|
||||
|
||||
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 approval_http_endpoints_enforce_request_rate_limit() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_approval_rate_limit");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-approval-rate-limit").await;
|
||||
let approval_key = create_approval_platform_api_key(
|
||||
®istry,
|
||||
"sales-approval-rate-limit",
|
||||
"approval-rate-limit",
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app_with_rate_limit(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
))
|
||||
.await;
|
||||
let approvals_url = format!(
|
||||
"{}/approvals",
|
||||
agent_mcp_url(&base_url, "sales-approval-rate-limit")
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let allowed = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(allowed.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let limited = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(limited.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(limited.headers().contains_key(header::RETRY_AFTER));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unverified_session_ids_do_not_create_approval_rate_limit_buckets() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_approval_session_rate_limit");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-approval-session-rate-limit").await;
|
||||
let approval_key = create_approval_platform_api_key(
|
||||
®istry,
|
||||
"sales-approval-session-rate-limit",
|
||||
"approval-session-rate-limit",
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app_with_rate_limit(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
))
|
||||
.await;
|
||||
let approvals_url = format!(
|
||||
"{}/approvals",
|
||||
agent_mcp_url(&base_url, "sales-approval-session-rate-limit")
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let allowed = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.header("MCP-Session-Id", "unverified-session-a")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(allowed.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let limited = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.header("MCP-Session-Id", "unverified-session-b")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(limited.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(limited.headers().contains_key(header::RETRY_AFTER));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_does_not_repeat_interrupted_mutating_approval() {
|
||||
let registry = test_registry().await;
|
||||
let (upstream_base_url, upstream_calls) = spawn_counted_approval_upstream().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_interrupted_approval");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-interrupted-approval").await;
|
||||
let approval_key_name = "approval-interrupted";
|
||||
create_approval_platform_api_key(®istry, "sales-interrupted-approval", approval_key_name)
|
||||
.await;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_interrupted_mutation"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-interrupted-approval"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: operation.version,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "interrupted@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: now - time::Duration::minutes(10),
|
||||
expires_at: now + 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_id = PlatformApiKeyId::new(format!("pk_{approval_key_name}"));
|
||||
registry
|
||||
.decide_approval_request(crank_registry::DecideApprovalRequest {
|
||||
workspace_id: &approval.workspace_id,
|
||||
agent_id: &approval.agent_id,
|
||||
approval_id: &approval.id,
|
||||
status: ApprovalRequestStatus::Approved,
|
||||
decided_at: now - time::Duration::minutes(10),
|
||||
decided_by_key_id: &approval_key_id,
|
||||
response_payload: Some(json!({"approve": "yes"})),
|
||||
decision_note: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry
|
||||
.claim_approval_request(
|
||||
&approval.workspace_id,
|
||||
&approval.agent_id,
|
||||
&approval.id,
|
||||
now - time::Duration::minutes(7),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let _app = build_test_app_with_approval_recovery(registry.clone());
|
||||
let failed = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let current = registry
|
||||
.get_approval_request_for_agent(
|
||||
&approval.workspace_id,
|
||||
&approval.agent_id,
|
||||
&approval.id,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if current.approval.status == ApprovalRequestStatus::Failed {
|
||||
break current;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("recovery must quarantine interrupted execution");
|
||||
|
||||
assert_eq!(
|
||||
failed.approval.response_payload.unwrap()["error"]["code"],
|
||||
"approval_execution_outcome_unknown"
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"recovery must not repeat a mutating upstream request"
|
||||
);
|
||||
}
|
||||
|
||||
fn build_test_app_with_approval_recovery(registry: PostgresRegistry) -> Router {
|
||||
crank_community_mcp::build_app_with_background_workers(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(RequestRateLimitConfig::new(10_000, 10_000).unwrap()),
|
||||
Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
Arc::new(InMemorySessionStore::default()),
|
||||
Arc::new(CommunityMachineCredentialVerifier),
|
||||
)
|
||||
}
|
||||
|
||||
async fn spawn_counted_approval_upstream() -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let handler_calls = Arc::clone(&calls);
|
||||
let app = Router::new().route(
|
||||
"/crm/leads",
|
||||
post(move |Json(payload): Json<Value>| {
|
||||
let calls = Arc::clone(&handler_calls);
|
||||
async move {
|
||||
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
(format!("http://{address}"), calls)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn elicitation_approval_requires_client_capability() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation");
|
||||
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Elicitation,
|
||||
risk_level: OperationApprovalRiskLevel::Normal,
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: Some("Подтвердите создание лида.".to_owned()),
|
||||
});
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::now_utc(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-elicitation-no-capability",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-elicitation-no-capability",
|
||||
"mcp-elicitation-no-capability",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-elicitation-no-capability");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_requires_elicitation",
|
||||
"arguments": {
|
||||
"email": "ada@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(tool_result["result"]["isError"], true);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["error"]["code"],
|
||||
"approval_elicitation_not_supported"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn elicitation_approval_uses_session_capability_without_approval_key() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation_supported");
|
||||
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Elicitation,
|
||||
risk_level: OperationApprovalRiskLevel::Normal,
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: Some("Подтвердите создание лида.".to_owned()),
|
||||
});
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::now_utc(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-elicitation-supported",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-elicitation-supported",
|
||||
"mcp-elicitation-supported",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-elicitation-supported");
|
||||
let initialized_session = initialize_session_with_capabilities(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
json!({ "elicitation": {} }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 8,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_requires_elicitation_supported",
|
||||
"arguments": {
|
||||
"email": "ada@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(tool_result["result"]["isError"], false);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["status"],
|
||||
"elicitation_required"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["message"],
|
||||
"Подтвердите создание лида."
|
||||
);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["payload_preview"]["email"],
|
||||
"ada@example.com"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
ToolSelectionPolicy, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
@@ -44,7 +45,7 @@ use crank_community_mcp::{
|
||||
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
|
||||
};
|
||||
|
||||
fn test_workspace_id() -> WorkspaceId {
|
||||
pub(super) fn test_workspace_id() -> WorkspaceId {
|
||||
WorkspaceId::new("ws_default")
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ fn test_agent_id(agent_slug: &str) -> AgentId {
|
||||
AgentId::new(format!("agent_{agent_slug}"))
|
||||
}
|
||||
|
||||
fn build_test_app(
|
||||
pub(super) fn build_test_app(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
@@ -134,7 +135,10 @@ fn build_test_app_with_store(
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
RuntimeExecutor::new(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(rate_limit_config),
|
||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
sessions,
|
||||
@@ -146,6 +150,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 +169,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 +250,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
|
||||
@@ -307,6 +363,21 @@ pub(super) async fn publish_agent_with_bindings(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
bindings: Vec<AgentOperationBinding>,
|
||||
) {
|
||||
publish_agent_with_policy(
|
||||
registry,
|
||||
agent_slug,
|
||||
bindings,
|
||||
ToolSelectionPolicy::default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn publish_agent_with_policy(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
bindings: Vec<AgentOperationBinding>,
|
||||
tool_selection_policy: ToolSelectionPolicy,
|
||||
) {
|
||||
let agent_id = AgentId::new(format!("agent_{agent_slug}"));
|
||||
let agent = Agent {
|
||||
@@ -327,7 +398,7 @@ pub(super) async fn publish_agent_with_bindings(
|
||||
version: 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: json!({}),
|
||||
tool_selection_policy: json!({}),
|
||||
tool_selection_policy,
|
||||
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
};
|
||||
|
||||
@@ -444,6 +515,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(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, Bytes, to_bytes},
|
||||
extract::State,
|
||||
http::{HeaderMap, Request, StatusCode, header},
|
||||
routing::post,
|
||||
};
|
||||
use crank_core::{InvocationSource, PlatformApiKeyScope};
|
||||
use crank_observability::{
|
||||
OtlpBatchConfig, OtlpTraceConfig, ServiceIdentity, build_tracer_provider,
|
||||
};
|
||||
use crank_registry::{ListInvocationLogsQuery, PublishRequest};
|
||||
use opentelemetry::global;
|
||||
use opentelemetry_proto::tonic::{
|
||||
collector::trace::v1::ExportTraceServiceRequest, common::v1::any_value, trace::v1::Span,
|
||||
};
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
use prost::Message;
|
||||
use serde_json::{Value, json};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
use tracing::instrument::WithSubscriber;
|
||||
use tracing_subscriber::{Layer, filter::filter_fn, layer::SubscriberExt};
|
||||
|
||||
use super::common::{
|
||||
build_test_app, create_platform_api_key, publish_agent_for_operation, test_operation,
|
||||
test_registry, test_workspace_id,
|
||||
};
|
||||
|
||||
const REMOTE_TRACE_ID: &str = "0af7651916cd43dd8448eb211c80319c";
|
||||
const REQUEST_ID: &str = "req_stage_end_to_end";
|
||||
const CANARY: &str = "dc-stage-canary-secret";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exports_real_tool_stages_without_sensitive_data() {
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
let (otlp_endpoint, request_rx, collector) = spawn_otlp_collector().await;
|
||||
let trace_config = OtlpTraceConfig::try_new(
|
||||
Some(otlp_endpoint),
|
||||
Some("http/protobuf".to_owned()),
|
||||
Duration::from_secs(2),
|
||||
OtlpBatchConfig::try_new(128, 128, Duration::from_secs(300), Duration::from_secs(2))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let identity = ServiceIdentity::try_new("mcp-server", "0.3.1", "integration-test").unwrap();
|
||||
let (provider, tracer) = build_tracer_provider(&identity, &trace_config)
|
||||
.unwrap()
|
||||
.expect("enabled OTLP provider");
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer)
|
||||
.with_filter(filter_fn(|metadata| {
|
||||
metadata.is_span() && metadata.target() == "crank::trace"
|
||||
})),
|
||||
);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
|
||||
let registry = test_registry().await;
|
||||
let observed_traceparent = Arc::new(Mutex::new(None));
|
||||
let upstream_base_url = spawn_upstream(Arc::clone(&observed_traceparent)).await;
|
||||
let operation = test_operation(&upstream_base_url, "stage_end_to_end");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("test"))
|
||||
.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("test"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "stage-agent").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"stage-agent",
|
||||
"stage-key",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let app = build_test_app(registry.clone(), Duration::ZERO, None);
|
||||
|
||||
let call_result = async {
|
||||
let initialized = send_jsonrpc(
|
||||
app.clone(),
|
||||
&api_key,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": {}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(initialized.status(), StatusCode::OK);
|
||||
let session_id = initialized
|
||||
.headers()
|
||||
.get("MCP-Session-Id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let notification = send_jsonrpc(
|
||||
app.clone(),
|
||||
&api_key,
|
||||
Some(&session_id),
|
||||
None,
|
||||
None,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(notification.status(), StatusCode::ACCEPTED);
|
||||
|
||||
send_jsonrpc(
|
||||
app,
|
||||
&api_key,
|
||||
Some(&session_id),
|
||||
Some(REQUEST_ID),
|
||||
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "stage_end_to_end",
|
||||
"arguments": { "email": CANARY }
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.with_subscriber(dispatch)
|
||||
.await;
|
||||
|
||||
assert_eq!(call_result.status(), StatusCode::OK);
|
||||
let body = to_bytes(call_result.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(body["result"]["isError"], false);
|
||||
provider.force_flush().unwrap();
|
||||
let request = tokio::time::timeout(Duration::from_secs(2), request_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
collector.abort();
|
||||
assert_eq!(
|
||||
request
|
||||
.headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/x-protobuf")
|
||||
);
|
||||
assert!(
|
||||
!request
|
||||
.body
|
||||
.windows(CANARY.len())
|
||||
.any(|window| window == CANARY.as_bytes())
|
||||
);
|
||||
let export = ExportTraceServiceRequest::decode(request.body).unwrap();
|
||||
let spans = export
|
||||
.resource_spans
|
||||
.iter()
|
||||
.flat_map(|resource| &resource.scope_spans)
|
||||
.flat_map(|scope| &scope.spans)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let traceparent = observed_traceparent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("upstream traceparent");
|
||||
assert_eq!(&traceparent[3..35], REMOTE_TRACE_ID);
|
||||
|
||||
let expected_trace_id = decode_trace_id(REMOTE_TRACE_ID);
|
||||
let trace_spans = spans
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|span| span.trace_id.as_slice() == expected_trace_id)
|
||||
.collect::<Vec<_>>();
|
||||
for expected in [
|
||||
"mcp.request",
|
||||
"mcp.rate_limit",
|
||||
"mcp.access.check",
|
||||
"mcp.catalog.load",
|
||||
"mcp.tools.resolve",
|
||||
"runtime.execute",
|
||||
"runtime.arguments.map",
|
||||
"upstream.http",
|
||||
"runtime.response.transform",
|
||||
"history.write",
|
||||
"db.query",
|
||||
] {
|
||||
assert!(
|
||||
trace_spans.iter().any(|span| span.name == expected),
|
||||
"missing span {expected}"
|
||||
);
|
||||
}
|
||||
assert!(!trace_spans.iter().any(|span| span.name == "approval.check"));
|
||||
assert!(
|
||||
!trace_spans
|
||||
.iter()
|
||||
.any(|span| span.name == "runtime.idempotency")
|
||||
);
|
||||
|
||||
let root = trace_spans
|
||||
.iter()
|
||||
.find(|span| span.name == "mcp.request")
|
||||
.expect("mcp root");
|
||||
let runtime = trace_spans
|
||||
.iter()
|
||||
.find(|span| span.name == "runtime.execute")
|
||||
.expect("runtime");
|
||||
assert!(!runtime.parent_span_id.is_empty());
|
||||
assert_eq!(runtime.parent_span_id, root.span_id);
|
||||
|
||||
let history = trace_spans
|
||||
.iter()
|
||||
.find(|span| span.name == "history.write")
|
||||
.expect("history write");
|
||||
let history_db = trace_spans
|
||||
.iter()
|
||||
.find(|span| {
|
||||
span.name == "db.query"
|
||||
&& string_attribute(span, "db.operation") == Some("invocation_history.write")
|
||||
})
|
||||
.expect("history PostgreSQL write");
|
||||
assert_eq!(history_db.parent_span_id, history.span_id);
|
||||
|
||||
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: None,
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(logs[0].log.request_id.as_deref(), Some(REQUEST_ID));
|
||||
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
struct OtlpRequest {
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
}
|
||||
|
||||
async fn spawn_otlp_collector() -> (
|
||||
String,
|
||||
tokio::sync::oneshot::Receiver<OtlpRequest>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (request_tx, request_rx) = tokio::sync::oneshot::channel();
|
||||
let sender = Arc::new(Mutex::new(Some(request_tx)));
|
||||
let app = Router::new().route(
|
||||
"/v1/traces",
|
||||
post({
|
||||
let sender = Arc::clone(&sender);
|
||||
move |headers: HeaderMap, body: Bytes| {
|
||||
let sender = Arc::clone(&sender);
|
||||
async move {
|
||||
if let Some(sender) = sender.lock().unwrap().take() {
|
||||
let _ = sender.send(OtlpRequest { headers, body });
|
||||
}
|
||||
StatusCode::OK
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let collector = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(format!("http://{address}/v1/traces"), request_rx, collector)
|
||||
}
|
||||
|
||||
fn decode_trace_id(value: &str) -> [u8; 16] {
|
||||
let mut bytes = [0_u8; 16];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).unwrap();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn string_attribute<'a>(span: &'a Span, key: &str) -> Option<&'a str> {
|
||||
span.attributes.iter().find_map(|attribute| {
|
||||
let value = attribute.value.as_ref()?.value.as_ref()?;
|
||||
(attribute.key == key)
|
||||
.then_some(value)
|
||||
.and_then(|value| match value {
|
||||
any_value::Value::StringValue(value) => Some(value.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_jsonrpc(
|
||||
app: Router,
|
||||
api_key: &str,
|
||||
session_id: Option<&str>,
|
||||
request_id: Option<&str>,
|
||||
traceparent: Option<&str>,
|
||||
payload: Value,
|
||||
) -> axum::response::Response {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/default/stage-agent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Protocol-Version", "2025-11-25");
|
||||
if let Some(session_id) = session_id {
|
||||
request = request.header("MCP-Session-Id", session_id);
|
||||
}
|
||||
if let Some(request_id) = request_id {
|
||||
request = request.header("x-request-id", request_id);
|
||||
}
|
||||
if let Some(traceparent) = traceparent {
|
||||
request = request.header("traceparent", traceparent);
|
||||
}
|
||||
app.oneshot(request.body(Body::from(payload.to_string())).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_upstream(observed: Arc<Mutex<Option<String>>>) -> String {
|
||||
async fn create_lead(
|
||||
State(observed): State<Arc<Mutex<Option<String>>>>,
|
||||
headers: HeaderMap,
|
||||
Json(_payload): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
*observed.lock().unwrap() = headers
|
||||
.get("traceparent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
Json(json!({ "id": "lead_123" }))
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/crm/leads", post(create_lead))
|
||||
.with_state(observed);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{address}")
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{HeaderValue, Request, StatusCode},
|
||||
};
|
||||
use opentelemetry::{
|
||||
global,
|
||||
trace::{TraceId, TracerProvider as _},
|
||||
};
|
||||
use opentelemetry_sdk::{
|
||||
error::OTelSdkResult,
|
||||
propagation::TraceContextPropagator,
|
||||
trace::{SdkTracerProvider, SpanData, SpanExporter},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
use tracing::instrument::WithSubscriber;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
use super::common::{build_test_app, test_registry};
|
||||
|
||||
const REMOTE_TRACE_ID: &str = "0af7651916cd43dd8448eb211c80319c";
|
||||
static TRACING_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn covers_valid_invalid_and_absent_traceparent_on_mcp_boundary() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
let exported = Arc::new(Mutex::new(Vec::new()));
|
||||
let provider = SdkTracerProvider::builder()
|
||||
.with_simple_exporter(CapturingExporter(Arc::clone(&exported)))
|
||||
.build();
|
||||
let tracer = provider.tracer("mcp-request-context-test");
|
||||
let subscriber =
|
||||
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let app = build_test_app(test_registry().await, Duration::ZERO, None);
|
||||
|
||||
let (valid, invalid, absent) = async {
|
||||
let valid = send_health(
|
||||
app.clone(),
|
||||
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"),
|
||||
Some("request-id-is-separate"),
|
||||
)
|
||||
.await;
|
||||
let invalid = send_health(
|
||||
app.clone(),
|
||||
Some("canary-invalid-traceparent"),
|
||||
Some("bad,value"),
|
||||
)
|
||||
.await;
|
||||
let absent = send_health(app, None, None).await;
|
||||
(valid, invalid, absent)
|
||||
}
|
||||
.with_subscriber(dispatch)
|
||||
.await;
|
||||
provider.force_flush().unwrap();
|
||||
|
||||
assert_eq!(valid.status, StatusCode::OK);
|
||||
assert_eq!(valid.request_id.as_deref(), Some("request-id-is-separate"));
|
||||
assert_eq!(invalid.status, StatusCode::OK);
|
||||
assert_eq!(absent.status, StatusCode::OK);
|
||||
assert!(valid.traceparent_response.is_none());
|
||||
assert!(invalid.traceparent_response.is_none());
|
||||
assert!(absent.traceparent_response.is_none());
|
||||
|
||||
let trace_ids: Vec<_> = exported
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|span| span.name.as_ref() == "mcp.request")
|
||||
.map(|span| span.span_context.trace_id())
|
||||
.collect();
|
||||
assert_eq!(trace_ids.len(), 3);
|
||||
assert_eq!(trace_ids[0].to_string(), REMOTE_TRACE_ID);
|
||||
assert_ne!(trace_ids[1], trace_ids[0]);
|
||||
assert_ne!(trace_ids[2], trace_ids[0]);
|
||||
assert_ne!(trace_ids[1], trace_ids[2]);
|
||||
assert!(!trace_ids.contains(&TraceId::INVALID));
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaces_multiple_request_id_headers_with_one_uuid_v7() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
let app = build_test_app(test_registry().await, Duration::ZERO, None);
|
||||
let mut request = Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.headers_mut()
|
||||
.append("x-request-id", HeaderValue::from_static("first-request-id"));
|
||||
request.headers_mut().append(
|
||||
"x-request-id",
|
||||
HeaderValue::from_static("second-request-id"),
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(request)
|
||||
.with_subscriber(tracing_subscriber::registry())
|
||||
.await
|
||||
.unwrap();
|
||||
let generated = response.headers()["x-request-id"].to_str().unwrap();
|
||||
|
||||
assert_ne!(generated, "first-request-id");
|
||||
assert_ne!(generated, "second-request-id");
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(uuid::Version::SortRand)
|
||||
);
|
||||
}
|
||||
|
||||
async fn send_health(
|
||||
app: axum::Router,
|
||||
traceparent: Option<&str>,
|
||||
request_id: Option<&str>,
|
||||
) -> ProbeResponse {
|
||||
let mut request = Request::builder().uri("/health");
|
||||
if let Some(traceparent) = traceparent {
|
||||
request = request.header("traceparent", traceparent);
|
||||
}
|
||||
if let Some(request_id) = request_id {
|
||||
request = request.header("x-request-id", request_id);
|
||||
}
|
||||
let response = app
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ProbeResponse {
|
||||
status: response.status(),
|
||||
request_id: response
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned),
|
||||
traceparent_response: response
|
||||
.headers()
|
||||
.get("traceparent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
struct ProbeResponse {
|
||||
status: StatusCode,
|
||||
request_id: Option<String>,
|
||||
traceparent_response: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CapturingExporter(Arc<Mutex<Vec<SpanData>>>);
|
||||
|
||||
impl SpanExporter for CapturingExporter {
|
||||
async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
|
||||
self.0.lock().unwrap().extend(batch);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use super::common::*;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crank_core::{
|
||||
PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings, ToolSelectionPolicy,
|
||||
};
|
||||
use crank_registry::PublishRequest;
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_mode_discovers_and_calls_tools_through_meta_tools() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let invoice = test_operation(&upstream_base_url, "create_invoice");
|
||||
let ticket = test_operation(&upstream_base_url, "create_support_ticket");
|
||||
for operation in [&invoice, &ticket] {
|
||||
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_with_policy(
|
||||
®istry,
|
||||
"business-search",
|
||||
vec![
|
||||
binding_for_operation(&invoice),
|
||||
binding_for_operation(&ticket),
|
||||
],
|
||||
ToolSelectionPolicy {
|
||||
mode: ToolAccessMode::Search,
|
||||
groups: vec![
|
||||
ToolGroup {
|
||||
id: "finance".to_owned(),
|
||||
name: "Finance".to_owned(),
|
||||
description: "Invoices and payments".to_owned(),
|
||||
tool_names: vec![invoice.name.clone()],
|
||||
},
|
||||
ToolGroup {
|
||||
id: "support".to_owned(),
|
||||
name: "Support".to_owned(),
|
||||
description: "Customer support tickets".to_owned(),
|
||||
tool_names: vec![ticket.name.clone()],
|
||||
},
|
||||
],
|
||||
search: ToolSearchSettings { max_results: 5 },
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"business-search",
|
||||
"mcp-search",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.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, "business-search");
|
||||
let session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let listed = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
listed["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["search_tools", "call_tool"]
|
||||
);
|
||||
|
||||
let search = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({
|
||||
"jsonrpc":"2.0","id":3,"method":"tools/call",
|
||||
"params":{"name":"search_tools","arguments":{"query":"invoice","group_ids":["finance"]}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
search["result"]["structuredContent"]["tools"][0]["name"],
|
||||
"create_invoice"
|
||||
);
|
||||
assert_eq!(
|
||||
search["result"]["structuredContent"]["catalog_revision"],
|
||||
"agent-version-1"
|
||||
);
|
||||
|
||||
let stale_call = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({
|
||||
"jsonrpc":"2.0","id":4,"method":"tools/call",
|
||||
"params":{"name":"call_tool","arguments":{
|
||||
"name":"create_invoice",
|
||||
"arguments":{"email":"user@example.com"},
|
||||
"catalog_revision":"agent-version-0"
|
||||
}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stale_call["result"]["isError"], true);
|
||||
assert_eq!(
|
||||
stale_call["result"]["structuredContent"]["error"]["code"],
|
||||
"catalog_revision_changed"
|
||||
);
|
||||
|
||||
let call = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({
|
||||
"jsonrpc":"2.0","id":5,"method":"tools/call",
|
||||
"params":{"name":"call_tool","arguments":{
|
||||
"name":"create_invoice",
|
||||
"arguments":{"email":"user@example.com"},
|
||||
"catalog_revision":"agent-version-1"
|
||||
}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(call["result"]["isError"], false);
|
||||
assert_eq!(call["result"]["structuredContent"]["id"], "lead_123");
|
||||
}
|
||||
@@ -19,7 +19,8 @@ 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,
|
||||
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolAccessMode, ToolDescription, ToolGroup,
|
||||
ToolSearchSettings, ToolSelectionPolicy, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
@@ -37,7 +38,8 @@ use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::sleep;
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
use uuid::Version;
|
||||
|
||||
use crank_community_mcp::{
|
||||
auth::{CommunityMachineCredentialVerifier, SharedMachineCredentialVerifier},
|
||||
@@ -45,6 +47,9 @@ use crank_community_mcp::{
|
||||
catalog::PublishedToolCatalog,
|
||||
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
|
||||
};
|
||||
use crank_observability::{
|
||||
ObservabilityConfig, RedactionLimits, ServiceIdentity, build_subscriber,
|
||||
};
|
||||
|
||||
fn test_workspace_id() -> WorkspaceId {
|
||||
WorkspaceId::new("ws_default")
|
||||
@@ -136,7 +141,10 @@ fn build_test_app_with_store(
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
RuntimeExecutor::new(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(rate_limit_config),
|
||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
sessions,
|
||||
@@ -400,7 +408,10 @@ async fn generates_request_id_for_tool_call_responses_and_logs() {
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
assert!(!request_id.is_empty());
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(&request_id).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
|
||||
let call_result = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(call_result["result"]["isError"], false);
|
||||
@@ -423,7 +434,7 @@ async fn generates_request_id_for_tool_call_responses_and_logs() {
|
||||
assert_eq!(logs[0].log.request_id.as_deref(), Some(request_id.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn emits_request_id_in_mcp_ingress_logs() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
@@ -452,6 +463,18 @@ async fn emits_request_id_in_mcp_ingress_logs() {
|
||||
)
|
||||
.await;
|
||||
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = build_subscriber(
|
||||
ObservabilityConfig::new(
|
||||
ServiceIdentity::try_new("mcp-server", "test", "test").unwrap(),
|
||||
"info",
|
||||
RedactionLimits::default(),
|
||||
),
|
||||
writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
@@ -460,18 +483,7 @@ async fn emits_request_id_in_mcp_ingress_logs() {
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-request-trace");
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(writer.clone())
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.with_filter(LevelFilter::INFO),
|
||||
);
|
||||
|
||||
let _ = tracing::subscriber::set_global_default(subscriber);
|
||||
let response = post_jsonrpc_response(
|
||||
&client,
|
||||
&mcp_url,
|
||||
@@ -496,14 +508,16 @@ async fn emits_request_id_in_mcp_ingress_logs() {
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let logs = writer.output();
|
||||
assert!(
|
||||
logs.contains("mcp request received"),
|
||||
"captured logs did not include ingress marker: {logs}"
|
||||
);
|
||||
assert!(logs.contains("req_mcp_trace_123"));
|
||||
assert!(logs.contains("sales-request-trace"));
|
||||
assert!(logs.contains("default"));
|
||||
assert!(logs.contains("initialize"));
|
||||
let event = logs
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.find(|event| event["event"] == "mcp.request.received")
|
||||
.unwrap();
|
||||
assert_eq!(event["service"], "mcp-server");
|
||||
assert_eq!(event["request_id"], "req_mcp_trace_123");
|
||||
assert_eq!(event["fields"]["agent_slug"], "sales-request-trace");
|
||||
assert_eq!(event["fields"]["workspace_slug"], "default");
|
||||
assert_eq!(event["fields"]["jsonrpc_method"], "initialize");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -689,6 +703,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()),
|
||||
)
|
||||
@@ -798,11 +813,30 @@ async fn get_requires_session_header() {
|
||||
.get(agent_mcp_url(&base_url, "sales-get-sse-missing"))
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("x-request-id", "req_early_mcp_error")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_early_mcp_error"
|
||||
);
|
||||
|
||||
let invalid_response = client
|
||||
.get(agent_mcp_url(&base_url, "sales-get-sse-missing"))
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("x-request-id", "bad,value")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let generated = invalid_response.headers()["x-request-id"].to_str().unwrap();
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
#[path = "integration/request_context.rs"]
|
||||
mod request_context;
|
||||
@@ -0,0 +1,97 @@
|
||||
/* Local font assets are copied from pinned @fontsource packages during the UI build. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/inter-cyrillic-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/inter-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/inter-cyrillic-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/inter-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/inter-cyrillic-600-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/inter-latin-600-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/inter-cyrillic-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/inter-latin-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/jetbrains-mono-cyrillic-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/jetbrains-mono-cyrillic-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/jetbrains-mono-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@@ -246,7 +246,7 @@ body.page-leaving .ws-setup-body {
|
||||
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
/* ── Responsive breakpoints ── */
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 980px) {
|
||||
.navbar { padding: 0 16px; }
|
||||
.nav-links { display: none; }
|
||||
.nav-hamburger { display: flex; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+25
-16
@@ -12,23 +12,26 @@
|
||||
.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: fixed;
|
||||
inset: 0;
|
||||
background: rgba(4, 8, 18, 0.72);
|
||||
backdrop-filter: blur(3px);
|
||||
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: calc(100vh - 56px);
|
||||
margin: 0 auto;
|
||||
@@ -37,8 +40,8 @@
|
||||
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 {
|
||||
@@ -47,6 +50,7 @@
|
||||
gap: 18px;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
|
||||
.openapi-import-header h2 {
|
||||
@@ -63,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 {
|
||||
@@ -85,7 +94,7 @@
|
||||
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;
|
||||
@@ -155,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 {
|
||||
@@ -184,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);
|
||||
}
|
||||
|
||||
@@ -196,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 {
|
||||
@@ -214,7 +223,7 @@
|
||||
#openapi-import-method-filter {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
background: var(--surface);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.openapi-import-bulk-actions {
|
||||
@@ -282,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;
|
||||
@@ -322,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 {
|
||||
@@ -344,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;
|
||||
}
|
||||
@@ -362,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;
|
||||
}
|
||||
@@ -387,7 +396,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.openapi-import-result-row {
|
||||
@@ -409,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 {
|
||||
|
||||
@@ -1403,6 +1403,177 @@
|
||||
.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; }
|
||||
.agents-rec-callout strong { color: var(--text-primary); }
|
||||
|
||||
.tool-access-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.tool-access-option {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-access-option:hover { border-color: var(--border-muted, #444c56); }
|
||||
.tool-access-option.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(45, 212, 191, 0.07);
|
||||
}
|
||||
.tool-access-option-title {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tool-access-option.active .tool-access-option-title { color: var(--accent); }
|
||||
.tool-access-option-body {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tool-search-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tool-search-config-header,
|
||||
.tool-group-card-header,
|
||||
.tool-search-preview-controls,
|
||||
.tool-search-result,
|
||||
.tool-group-assignment-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.tool-search-config-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-search-config-header .drawer-section-sub { margin: 3px 0 0; }
|
||||
.tool-search-config-header .btn-ghost-sm { white-space: nowrap; }
|
||||
.tool-group-empty {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 7px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tool-group-card,
|
||||
.tool-search-preview {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.tool-group-card-header { margin-bottom: 10px; }
|
||||
.tool-group-card-header strong { font-size: 12px; color: var(--text-primary); }
|
||||
.tool-group-card-header .agent-action-btn img { width: 13px; height: 13px; }
|
||||
.tool-group-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.tool-group-fields label,
|
||||
.tool-group-description,
|
||||
.tool-search-limit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tool-group-description { margin-top: 9px; }
|
||||
.tool-group-assignments {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-group-assignments > .tool-search-config-title {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.tool-group-assignment-row {
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tool-group-assignment-row:last-child { border-bottom: 0; }
|
||||
.tool-group-assignment-tool { min-width: 130px; }
|
||||
.tool-group-assignment-tool strong,
|
||||
.tool-group-assignment-tool code,
|
||||
.tool-search-result strong,
|
||||
.tool-search-result code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-group-assignment-tool strong,
|
||||
.tool-search-result strong { color: var(--text-primary); font-size: 11px; }
|
||||
.tool-group-assignment-tool code,
|
||||
.tool-search-result code { margin-top: 3px; color: var(--text-muted); font-size: 10px; }
|
||||
.tool-group-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
.tool-group-chip {
|
||||
max-width: 150px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-group-chip.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(45, 212, 191, 0.08);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tool-search-limit { max-width: 190px; }
|
||||
.tool-search-preview .drawer-section-sub { margin-top: 4px; }
|
||||
.tool-search-preview-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 132px auto;
|
||||
}
|
||||
.tool-search-results {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tool-search-result { padding: 9px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.tool-search-result:last-child { border-bottom: 0; }
|
||||
.tool-search-result > div { min-width: 0; }
|
||||
.tool-search-result > span { color: var(--text-muted); font-size: 10px; }
|
||||
.tool-search-preview > .tool-group-empty { margin-top: 10px; }
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.tool-access-options,
|
||||
.tool-group-fields,
|
||||
.tool-search-preview-controls { grid-template-columns: 1fr; }
|
||||
.tool-group-assignment-row { flex-direction: column; }
|
||||
.tool-group-chips { justify-content: flex-start; }
|
||||
.tool-search-limit { max-width: none; }
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Settings — Members enhanced
|
||||
|
||||
+210
-2
@@ -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%;
|
||||
}
|
||||
@@ -412,7 +467,11 @@
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
@@ -1058,6 +1117,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 +1334,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;
|
||||
@@ -1732,6 +1828,118 @@
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.progress-strip {
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wizard-body {
|
||||
display: grid;
|
||||
padding: 24px 16px 120px;
|
||||
}
|
||||
|
||||
.step-sidebar {
|
||||
position: static;
|
||||
width: auto;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.step-sidebar-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.step-sidebar-header {
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.steps-list::before {
|
||||
left: calc((100% - 32px) / 10);
|
||||
right: calc((100% - 32px) / 10);
|
||||
top: 27px;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-height: 96px;
|
||||
padding: 10px 6px 9px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-help {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 100%;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
margin-bottom: 3px;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
display: -webkit-box;
|
||||
min-height: 28px;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.step-status-text {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.action-bar-inner {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.progress-label,
|
||||
.progress-pct,
|
||||
.btn-save-draft,
|
||||
.step-counter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
min-height: 88px;
|
||||
padding: 9px 4px 8px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-status-text {
|
||||
font-size: 9.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════
|
||||
HTTP method picker (Step 4 REST)
|
||||
══════════════════════════════════════════════ */
|
||||
|
||||
@@ -37,7 +37,11 @@
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); }
|
||||
@@ -107,6 +111,7 @@
|
||||
.ws-color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
|
||||
+105
-7
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agents</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -49,12 +49,12 @@
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role" id="user-ws-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" onclick="window.location.href='/settings'">
|
||||
<a class="user-dropdown-item" href="/settings">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
|
||||
<button class="user-dropdown-item danger" @click="window.CrankAuth.logout()">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
@@ -359,24 +359,122 @@
|
||||
<!-- Footer count -->
|
||||
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
|
||||
<span x-text="tfKey('agents.drawer.ops_selected', { count: form.selectedOps.length })"></span>
|
||||
<button @click="form.selectedOps = []" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
|
||||
<button @click="clearSelectedOperations()" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation callout -->
|
||||
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15">
|
||||
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15 && form.accessMode === 'direct'">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
|
||||
<span x-text="tfKey('agents.drawer.recommendation', { count: form.selectedOps.length })">You've selected tools.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-title" data-i18n="agents.drawer.access.title">Tool access</div>
|
||||
<div class="drawer-section-sub" data-i18n="agents.drawer.access.subtitle">Choose how the model receives this agent's tool catalog.</div>
|
||||
|
||||
<div class="tool-access-options">
|
||||
<button class="tool-access-option" :class="{ active: form.accessMode === 'direct' }" @click="setAccessMode('direct')">
|
||||
<span class="tool-access-option-title" data-i18n="agents.drawer.access.direct">Show tools immediately</span>
|
||||
<span class="tool-access-option-body" data-i18n="agents.drawer.access.direct_hint">Best for a small curated catalog. MCP clients receive every tool in tools/list.</span>
|
||||
</button>
|
||||
<button class="tool-access-option" :class="{ active: form.accessMode === 'search' }" @click="setAccessMode('search')">
|
||||
<span class="tool-access-option-title" data-i18n="agents.drawer.access.search">Select tools on demand</span>
|
||||
<span class="tool-access-option-body" data-i18n="agents.drawer.access.search_hint">The model sees search_tools and call_tool, then discovers only relevant schemas.</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-search-config" x-show="form.accessMode === 'search'" style="display:none">
|
||||
<div class="tool-search-config-header">
|
||||
<div>
|
||||
<div class="tool-search-config-title" data-i18n="agents.drawer.groups.title">Catalog sections</div>
|
||||
<div class="drawer-section-sub" data-i18n="agents.drawer.groups.subtitle">Sections help the model narrow a search without blocking catalog-wide discovery.</div>
|
||||
</div>
|
||||
<button class="btn-ghost-sm" @click="addToolGroup()" data-i18n="agents.drawer.groups.add">Add section</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-group-empty" x-show="form.groups.length === 0" data-i18n="agents.drawer.groups.empty">No sections yet. Search will use the entire selected catalog.</div>
|
||||
<template x-for="(group, groupIndex) in form.groups" :key="groupIndex">
|
||||
<div class="tool-group-card">
|
||||
<div class="tool-group-card-header">
|
||||
<strong x-text="group.name || tKey('agents.drawer.groups.untitled')"></strong>
|
||||
<button class="agent-action-btn danger" @click="removeToolGroup(groupIndex)" :title="tKey('agents.drawer.groups.remove')">
|
||||
<img src="/icons/general/trash.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-group-fields">
|
||||
<label>
|
||||
<span data-i18n="agents.drawer.groups.name">Name</span>
|
||||
<input class="form-input" type="text" :value="group.name" @input="onToolGroupName(groupIndex, $event.target.value)" data-i18n-ph="agents.drawer.groups.name_placeholder" placeholder="Finance">
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="agents.drawer.groups.id">Identifier</span>
|
||||
<input class="form-input input-mono" type="text" :value="group.id" @input="onToolGroupId(groupIndex, $event.target.value)" placeholder="finance">
|
||||
</label>
|
||||
</div>
|
||||
<label class="tool-group-description">
|
||||
<span data-i18n="agents.drawer.groups.description">Description for the model</span>
|
||||
<textarea class="form-textarea" rows="2" x-model="group.description" @input="resetSearchPreview()" data-i18n-ph="agents.drawer.groups.description_placeholder" placeholder="Invoices, payments and refunds"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="tool-group-assignments" x-show="form.groups.length > 0 && selectedOperations.length > 0">
|
||||
<div class="tool-search-config-title" data-i18n="agents.drawer.groups.assign">Assign tools to sections</div>
|
||||
<template x-for="operation in selectedOperations" :key="operation.id">
|
||||
<div class="tool-group-assignment-row">
|
||||
<div class="tool-group-assignment-tool">
|
||||
<strong x-text="operation.display_name || operation.name"></strong>
|
||||
<code x-text="operation.name"></code>
|
||||
</div>
|
||||
<div class="tool-group-chips">
|
||||
<template x-for="(group, groupIndex) in form.groups" :key="groupIndex">
|
||||
<button class="tool-group-chip" :class="{ active: toolInGroup(groupIndex, operation.name) }" @click="toggleToolGroup(groupIndex, operation.name)" x-text="group.name || group.id || '—'"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<label class="tool-search-limit">
|
||||
<span data-i18n="agents.drawer.search.limit">Maximum results per search</span>
|
||||
<input class="form-input" type="number" min="1" max="20" x-model.number="form.searchMaxResults" @input="resetSearchPreview()">
|
||||
</label>
|
||||
|
||||
<div class="tool-search-preview">
|
||||
<div class="tool-search-config-title" data-i18n="agents.drawer.search.preview_title">Test tool selection</div>
|
||||
<div class="drawer-section-sub" data-i18n="agents.drawer.search.preview_subtitle">Enter a task and verify which tools the model will receive.</div>
|
||||
<div class="tool-search-preview-controls">
|
||||
<input class="form-input" type="text" x-model="searchPreviewQuery" @keydown.enter.prevent="previewToolSearch()" data-i18n-ph="agents.drawer.search.query_placeholder" placeholder="Create an invoice for a customer">
|
||||
<select class="form-select" x-model="searchPreviewGroup">
|
||||
<option value="" data-i18n="agents.drawer.search.all_groups">All sections</option>
|
||||
<template x-for="group in form.groups" :key="group.id">
|
||||
<option :value="group.id" x-text="group.name || group.id"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn-primary-sm" :disabled="searchPreviewLoading || !searchPreviewQuery.trim() || !catalogConfigValid" @click="previewToolSearch()" x-text="searchPreviewLoading ? tKey('agents.drawer.search.testing') : tKey('agents.drawer.search.test')">Test</button>
|
||||
</div>
|
||||
<div class="tool-search-results" x-show="searchPreviewItems.length > 0">
|
||||
<template x-for="item in searchPreviewItems" :key="item.tool.name">
|
||||
<div class="tool-search-result">
|
||||
<div><strong x-text="item.tool.title"></strong><code x-text="item.tool.name"></code></div>
|
||||
<span x-text="item.tool.group_ids.join(', ')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="tool-group-empty" x-show="searchPreviewRan && !searchPreviewLoading && searchPreviewItems.length === 0" data-i18n="agents.drawer.search.no_results">No tools matched this task.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /drawer-body -->
|
||||
|
||||
<!-- Drawer footer -->
|
||||
<div class="drawer-footer">
|
||||
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()" data-i18n="agents.drawer.cancel">Cancel</button>
|
||||
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
|
||||
:disabled="!form.display_name.trim() || !form.slug.trim()"
|
||||
:disabled="!form.display_name.trim() || !form.slug.trim() || !catalogConfigValid"
|
||||
@click="saveAgent()"
|
||||
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
|
||||
Create agent
|
||||
|
||||
+87
-26
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agent Keys</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -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">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<div class="field-group" style="margin-top:8px;">
|
||||
<label class="field-label">Interface language</label>
|
||||
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="lang-btn" data-lang="en" onclick="setLang('en')">
|
||||
<button class="lang-btn" data-lang="en">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
|
||||
<button class="lang-btn" data-lang="ru">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Sign in</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/login.css">
|
||||
<script src="%CRANK_BUNDLE_LOGIN%"></script>
|
||||
|
||||
+15
-1
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Logs</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Secrets</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Settings</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -172,11 +172,11 @@
|
||||
<label class="field-label" data-i18n="settings.lang.title">Language</label>
|
||||
<div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div>
|
||||
<div class="lang-switcher">
|
||||
<button class="lang-btn" data-lang="en" type="button" onclick="setLang('en')">
|
||||
<button class="lang-btn" data-lang="en" type="button">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru" type="button" onclick="setLang('ru')">
|
||||
<button class="lang-btn" data-lang="ru" type="button">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Usage</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — New Operation</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../../css/fonts.css">
|
||||
<link rel="stylesheet" href="../../css/variables.css">
|
||||
<link rel="stylesheet" href="../../css/layout.css">
|
||||
<link rel="stylesheet" href="../../css/wizard.css">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- Searchable combobox -->
|
||||
<div class="upstream-combobox" id="upstream-combobox">
|
||||
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" onclick="toggleUpstreamDropdown(event)">
|
||||
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" data-wizard-action="toggle-upstream">
|
||||
<div class="upstream-combobox-value" id="upstream-combobox-value">
|
||||
<span class="upstream-combobox-placeholder" data-i18n="wizard.step2.upstream_placeholder">Выберите API-хост…</span>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/>
|
||||
</svg>
|
||||
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" oninput="filterUpstreams(this.value)" autocomplete="off" spellcheck="false">
|
||||
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="upstream-dropdown-list" id="upstream-dropdown-list">
|
||||
<!-- populated by JS -->
|
||||
@@ -58,11 +58,11 @@
|
||||
<div class="upstream-preview-name" id="upstream-preview-name"></div>
|
||||
<div class="upstream-preview-url" id="upstream-preview-url"></div>
|
||||
<span class="upstream-auth-badge" id="upstream-preview-badge"></span>
|
||||
<button class="upstream-preview-change" onclick="beginEditSelectedUpstream(event)" data-i18n="wizard.step2.change">Изменить</button>
|
||||
<button class="upstream-preview-change" data-wizard-action="edit-upstream" data-i18n="wizard.step2.change">Изменить</button>
|
||||
</div>
|
||||
|
||||
<!-- Register new upstream trigger row -->
|
||||
<div class="upstream-new-trigger" id="upstream-new-trigger" onclick="startNewUpstream()">
|
||||
<div class="upstream-new-trigger" id="upstream-new-trigger" data-wizard-action="new-upstream">
|
||||
<div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio">
|
||||
<div class="upstream-new-trigger-dot"></div>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.auth_selector">Авторизация API-хоста</label>
|
||||
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select" onchange="updateUpstreamAuthUi()">
|
||||
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select">
|
||||
<option value="none" data-i18n="wizard.step2.auth_mode.none">Без авторизации</option>
|
||||
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Использовать существующий профиль авторизации</option>
|
||||
<option value="create" data-i18n="wizard.step2.auth_mode.create">Создать профиль авторизации сейчас</option>
|
||||
@@ -118,7 +118,7 @@
|
||||
<div class="form-row" style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Тип авторизации</span> <span class="form-label-required" data-i18n="workspace_setup.required">обязательно</span></label>
|
||||
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select" onchange="updateAuthProfileCreateUi()">
|
||||
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select">
|
||||
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer-токен</option>
|
||||
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Логин и пароль</option>
|
||||
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API-ключ в заголовке</option>
|
||||
@@ -149,7 +149,7 @@
|
||||
<select class="form-select" id="new-auth-profile-secret-id" data-testid="wizard-auth-secret-select"></select>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" onclick="openQuickSecretModal(event)" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button>
|
||||
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" data-wizard-action="quick-secret" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button>
|
||||
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Открыть страницу секретов</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,8 +168,8 @@
|
||||
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Необязательные заголовки без секретных значений. Токены, пароли и ключи храните через профиль авторизации.</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:4px;">
|
||||
<button class="btn-primary-sm" onclick="saveNewUpstream(event)" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button>
|
||||
<button class="btn-ghost-sm" onclick="cancelNewUpstream(event)" data-i18n="btn.cancel">Отмена</button>
|
||||
<button class="btn-primary-sm" data-wizard-action="save-upstream" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button>
|
||||
<button class="btn-ghost-sm" data-wizard-action="cancel-upstream" data-i18n="btn.cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,23 +25,23 @@
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="method-grid">
|
||||
<button class="method-card" data-method="GET" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="GET">
|
||||
<span class="method-name">GET</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.read">Чтение</span>
|
||||
</button>
|
||||
<button class="method-card active" data-method="POST" onclick="selectMethod(this)">
|
||||
<button class="method-card active" data-method="POST">
|
||||
<span class="method-name">POST</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.create">Создание</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="PUT" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="PUT">
|
||||
<span class="method-name">PUT</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.replace">Замена</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="PATCH" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="PATCH">
|
||||
<span class="method-name">PATCH</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.update">Обновление</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="DELETE" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="DELETE">
|
||||
<span class="method-name">DELETE</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.remove">Удаление</span>
|
||||
</button>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Workspace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -24,10 +24,10 @@
|
||||
</div>
|
||||
Crank
|
||||
</a>
|
||||
<a href="javascript:history.back()" class="ws-setup-back">
|
||||
<button type="button" class="ws-setup-back" data-history-back>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
||||
<span data-i18n="workspace_setup.back">Back</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -48,27 +48,27 @@
|
||||
<div>
|
||||
<div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div>
|
||||
<div class="ws-color-swatches">
|
||||
<div class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" onclick="pickColor(this)" title="Teal"></div>
|
||||
<div class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" onclick="pickColor(this)" title="Purple"></div>
|
||||
<div class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" onclick="pickColor(this)" title="Cyan"></div>
|
||||
<div class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" onclick="pickColor(this)" title="Amber"></div>
|
||||
<div class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" onclick="pickColor(this)" title="Green"></div>
|
||||
<div class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" onclick="pickColor(this)" title="Red"></div>
|
||||
<div class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" onclick="pickColor(this)" title="Indigo"></div>
|
||||
<button class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" type="button" title="Teal"></button>
|
||||
<button class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" type="button" title="Purple"></button>
|
||||
<button class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" type="button" title="Cyan"></button>
|
||||
<button class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" type="button" title="Amber"></button>
|
||||
<button class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" type="button" title="Green"></button>
|
||||
<button class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" type="button" title="Red"></button>
|
||||
<button class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" type="button" title="Indigo"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:14px;">
|
||||
<label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off" oninput="onWsNameInput(this.value)">
|
||||
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:14px;">
|
||||
<label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<div style="position:relative;">
|
||||
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
|
||||
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;" oninput="onWsSlugInput(this.value)">
|
||||
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;">
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
|
||||
</div>
|
||||
@@ -81,8 +81,8 @@
|
||||
|
||||
<!-- ── Actions ── -->
|
||||
<div class="ws-setup-actions">
|
||||
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</a>
|
||||
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" onclick="submitForm()" data-i18n="workspace_setup.actions.save">Save changes</button>
|
||||
<button type="button" class="btn-ghost-sm" data-history-back style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</button>
|
||||
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" data-i18n="workspace_setup.actions.save">Save changes</button>
|
||||
</div>
|
||||
|
||||
<div class="ws-setup-footer-note" id="footer-note" hidden>
|
||||
@@ -97,8 +97,8 @@
|
||||
</div>
|
||||
<div class="danger-zone-action">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title" data-i18n="workspace_setup.danger.export_title">Export all data</div>
|
||||
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.export_body">Download a JSON snapshot of workspace settings, operations, agents, secrets, usage data and agent access keys.</div>
|
||||
<div class="danger-zone-title" data-i18n="workspace_setup.danger.export_title">Export workspace catalog</div>
|
||||
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.export_body">Download a non-restorable JSON catalog of workspace settings, operation summaries, agent summaries and API key metadata.</div>
|
||||
</div>
|
||||
<button class="btn-danger" id="export-workspace-btn" type="button" data-i18n="workspace_setup.export">Export</button>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Operations</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
|
||||
+192
-20
@@ -14,6 +14,7 @@ function mapAgent(agent) {
|
||||
raw_status: agent.status,
|
||||
operation_count: agent.operation_count || 0,
|
||||
operation_ids: agent.operation_ids || [],
|
||||
tool_selection_policy: agent.tool_selection_policy || { mode: 'direct', groups: [], search: { max_results: 8 } },
|
||||
key_count: agent.key_count || 0,
|
||||
calls_today: agent.calls_today || 0,
|
||||
created_at: agent.created_at,
|
||||
@@ -66,10 +67,18 @@ document.addEventListener('alpine:init', function() {
|
||||
description: '',
|
||||
status: 'published',
|
||||
selectedOps: [],
|
||||
accessMode: 'direct',
|
||||
groups: [],
|
||||
searchMaxResults: 8,
|
||||
},
|
||||
|
||||
opSearch: '',
|
||||
slugManuallyEdited: false,
|
||||
searchPreviewQuery: '',
|
||||
searchPreviewGroup: '',
|
||||
searchPreviewItems: [],
|
||||
searchPreviewLoading: false,
|
||||
searchPreviewRan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
@@ -205,7 +214,7 @@ document.addEventListener('alpine:init', function() {
|
||||
get agentToolFindings() {
|
||||
var findings = [];
|
||||
var selected = this.selectedOperations;
|
||||
if (selected.length > 8) {
|
||||
if (selected.length > 8 && this.form.accessMode === 'direct') {
|
||||
findings.push(this.tKey('agents.drawer.finding.too_many_tools'));
|
||||
}
|
||||
|
||||
@@ -242,13 +251,18 @@ document.addEventListener('alpine:init', function() {
|
||||
description: '',
|
||||
status: 'published',
|
||||
selectedOps: [],
|
||||
accessMode: 'direct',
|
||||
groups: [],
|
||||
searchMaxResults: 8,
|
||||
};
|
||||
this.opSearch = '';
|
||||
this.slugManuallyEdited = false;
|
||||
this.resetSearchPreview();
|
||||
this.drawerOpen = true;
|
||||
},
|
||||
|
||||
openEdit(agent) {
|
||||
var policy = agent.tool_selection_policy || {};
|
||||
this.drawerMode = 'edit';
|
||||
this.editingId = agent.id;
|
||||
this.form = {
|
||||
@@ -257,9 +271,22 @@ document.addEventListener('alpine:init', function() {
|
||||
description: agent.description,
|
||||
status: agent.raw_status || agent.status || 'draft',
|
||||
selectedOps: [].concat(agent.operation_ids || []),
|
||||
accessMode: policy.mode === 'search' ? 'search' : 'direct',
|
||||
groups: (policy.groups || []).map(function(group) {
|
||||
return {
|
||||
id: group.id || '',
|
||||
name: group.name || '',
|
||||
description: group.description || '',
|
||||
tool_names: [].concat(group.tool_names || []),
|
||||
};
|
||||
}),
|
||||
searchMaxResults: policy.search && policy.search.max_results
|
||||
? policy.search.max_results
|
||||
: 8,
|
||||
};
|
||||
this.opSearch = '';
|
||||
this.slugManuallyEdited = true;
|
||||
this.resetSearchPreview();
|
||||
this.drawerOpen = true;
|
||||
},
|
||||
|
||||
@@ -289,13 +316,170 @@ document.addEventListener('alpine:init', function() {
|
||||
this.form.selectedOps.push(operationId);
|
||||
} else {
|
||||
this.form.selectedOps.splice(index, 1);
|
||||
var operation = this.operations.find(function(item) { return item.id === operationId; });
|
||||
if (operation) {
|
||||
this.form.groups.forEach(function(group) {
|
||||
group.tool_names = group.tool_names.filter(function(name) {
|
||||
return name !== operation.name;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
isOpSelected(operationId) {
|
||||
return this.form.selectedOps.includes(operationId);
|
||||
},
|
||||
|
||||
clearSelectedOperations() {
|
||||
this.form.selectedOps = [];
|
||||
this.form.groups.forEach(function(group) { group.tool_names = []; });
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
setAccessMode(mode) {
|
||||
this.form.accessMode = mode === 'search' ? 'search' : 'direct';
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
addToolGroup() {
|
||||
this.form.groups.push({ id: '', name: '', description: '', tool_names: [] });
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
removeToolGroup(index) {
|
||||
this.form.groups.splice(index, 1);
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
onToolGroupName(index, value) {
|
||||
var group = this.form.groups[index];
|
||||
if (!group) return;
|
||||
var previousSlug = this.slugifyGroupName(group.name);
|
||||
group.name = value;
|
||||
if (!group.id || group.id === previousSlug) {
|
||||
group.id = this.slugifyGroupName(value);
|
||||
}
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
onToolGroupId(index, value) {
|
||||
var group = this.form.groups[index];
|
||||
if (!group) return;
|
||||
group.id = this.slugifyGroupName(value);
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
slugifyGroupName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
},
|
||||
|
||||
toggleToolGroup(groupIndex, toolName) {
|
||||
var group = this.form.groups[groupIndex];
|
||||
if (!group) return;
|
||||
var index = group.tool_names.indexOf(toolName);
|
||||
if (index === -1) group.tool_names.push(toolName);
|
||||
else group.tool_names.splice(index, 1);
|
||||
this.resetSearchPreview();
|
||||
},
|
||||
|
||||
toolInGroup(groupIndex, toolName) {
|
||||
var group = this.form.groups[groupIndex];
|
||||
return Boolean(group && group.tool_names.includes(toolName));
|
||||
},
|
||||
|
||||
toolSelectionPolicy() {
|
||||
return {
|
||||
mode: this.form.accessMode,
|
||||
groups: this.form.accessMode === 'search'
|
||||
? this.form.groups.map(function(group) {
|
||||
return {
|
||||
id: group.id.trim(),
|
||||
name: group.name.trim(),
|
||||
description: group.description.trim(),
|
||||
tool_names: [].concat(group.tool_names || []),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
search: {
|
||||
max_results: Math.max(1, Math.min(20, Number(this.form.searchMaxResults) || 8)),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
get catalogConfigValid() {
|
||||
if (this.form.accessMode !== 'search') return true;
|
||||
var ids = [];
|
||||
for (var index = 0; index < this.form.groups.length; index += 1) {
|
||||
var group = this.form.groups[index];
|
||||
if (!group.id.trim() || !group.name.trim() || !group.description.trim()) return false;
|
||||
if (ids.includes(group.id.trim())) return false;
|
||||
ids.push(group.id.trim());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
agentBindings() {
|
||||
var self = this;
|
||||
return this.form.selectedOps.map(function(operationId) {
|
||||
var operation = self.operations.find(function(item) { return item.id === operationId; });
|
||||
return {
|
||||
operation_id: operationId,
|
||||
operation_version: operation && operation.latest_published_version
|
||||
? operation.latest_published_version
|
||||
: operation && operation.current_draft_version
|
||||
? operation.current_draft_version
|
||||
: 1,
|
||||
tool_name: operation ? operation.name : operationId,
|
||||
tool_title: operation ? (operation.display_name || operation.name) : operationId,
|
||||
tool_description_override: null,
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
resetSearchPreview() {
|
||||
this.searchPreviewItems = [];
|
||||
this.searchPreviewRan = false;
|
||||
},
|
||||
|
||||
async previewToolSearch() {
|
||||
if (
|
||||
this.searchPreviewLoading
|
||||
|| !this.workspaceId
|
||||
|| !this.searchPreviewQuery.trim()
|
||||
|| this.form.accessMode !== 'search'
|
||||
) return;
|
||||
this.searchPreviewLoading = true;
|
||||
this.searchPreviewRan = false;
|
||||
try {
|
||||
var response = await window.CrankApi.previewAgentToolSearch(this.workspaceId, {
|
||||
query: this.searchPreviewQuery.trim(),
|
||||
group_ids: this.searchPreviewGroup ? [this.searchPreviewGroup] : [],
|
||||
bindings: this.agentBindings(),
|
||||
tool_selection_policy: this.toolSelectionPolicy(),
|
||||
});
|
||||
this.searchPreviewItems = response.items || [];
|
||||
this.searchPreviewRan = true;
|
||||
} catch (error) {
|
||||
this.searchPreviewItems = [];
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || this.tKey('agents.drawer.search.preview_error'),
|
||||
this.tKey('agents.drawer.search.preview_error_title')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.searchPreviewLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
operationsLookSimilar(left, right) {
|
||||
var leftTokens = this.operationTokens(left);
|
||||
var rightTokens = this.operationTokens(right);
|
||||
@@ -345,7 +529,7 @@ document.addEventListener('alpine:init', function() {
|
||||
display_name: this.form.display_name,
|
||||
description: this.form.description,
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
tool_selection_policy: this.toolSelectionPolicy(),
|
||||
});
|
||||
agentId = created.agent_id;
|
||||
currentVersion = created.version || 1;
|
||||
@@ -359,27 +543,15 @@ document.addEventListener('alpine:init', function() {
|
||||
currentVersion = agent.current_draft_version || 1;
|
||||
}
|
||||
|
||||
await window.CrankApi.saveAgentBindings(
|
||||
var savedVersion = await window.CrankApi.saveAgentBindings(
|
||||
this.workspaceId,
|
||||
agentId,
|
||||
this.form.selectedOps.map(function(operationId) {
|
||||
var operation = self.operations.find(function(item) {
|
||||
return item.id === operationId;
|
||||
});
|
||||
return {
|
||||
operation_id: operationId,
|
||||
operation_version: operation && operation.latest_published_version
|
||||
? operation.latest_published_version
|
||||
: operation && operation.current_draft_version
|
||||
? operation.current_draft_version
|
||||
: 1,
|
||||
tool_name: operation ? operation.name : operationId,
|
||||
tool_title: operation ? (operation.display_name || operation.name) : operationId,
|
||||
tool_description_override: null,
|
||||
enabled: true,
|
||||
};
|
||||
}),
|
||||
{
|
||||
bindings: this.agentBindings(),
|
||||
tool_selection_policy: this.toolSelectionPolicy(),
|
||||
},
|
||||
);
|
||||
currentVersion = savedVersion.version || currentVersion;
|
||||
|
||||
if (this.form.status === 'published') {
|
||||
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
|
||||
|
||||
+82
-8
@@ -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() {
|
||||
|
||||
+9
-8
@@ -124,14 +124,6 @@
|
||||
return encoded ? ('?' + encoded) : '';
|
||||
}
|
||||
|
||||
function postBytes(path, bytes, fileName) {
|
||||
return request(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
|
||||
body: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
window.CrankApi = {
|
||||
login: function(payload) {
|
||||
return request(AUTH_BASE + '/login', {
|
||||
@@ -270,6 +262,9 @@
|
||||
saveAgentBindings: function(workspaceId, agentId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/bindings', payload);
|
||||
},
|
||||
previewAgentToolSearch: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/tool-search/preview', payload);
|
||||
},
|
||||
publishAgent: function(workspaceId, agentId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
|
||||
},
|
||||
@@ -327,6 +322,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));
|
||||
},
|
||||
|
||||
+168
-8
@@ -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',
|
||||
@@ -408,8 +442,8 @@ var TRANSLATIONS = {
|
||||
'workspace_setup.create.subtitle': 'This Community installation uses one workspace for MCP operations and agents.',
|
||||
'workspace_setup.create.footer': 'This Community installation uses one workspace.',
|
||||
'workspace_setup.danger.title': 'Danger zone',
|
||||
'workspace_setup.danger.export_title': 'Export all data',
|
||||
'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, operations, agents, secrets, usage data and agent access keys.',
|
||||
'workspace_setup.danger.export_title': 'Export workspace catalog',
|
||||
'workspace_setup.danger.export_body': 'Download a non-restorable JSON catalog of workspace settings, operation summaries, agent summaries and API key metadata.',
|
||||
'workspace_setup.export': 'Export',
|
||||
'workspace_setup.role.owner': 'Owner',
|
||||
'workspace_setup.role.admin': 'Admin',
|
||||
@@ -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',
|
||||
@@ -767,13 +819,41 @@ var TRANSLATIONS = {
|
||||
'agents.drawer.operations_sub': 'Select the MCP tools available to this agent.',
|
||||
'agents.drawer.operations_sub_community': 'Select the MCP tools available to this agent.',
|
||||
'agents.drawer.finding.title': 'Recommendation',
|
||||
'agents.drawer.finding.too_many_tools': 'This agent has many tools. Keep only the tools needed for one concrete task.',
|
||||
'agents.drawer.finding.too_many_tools': 'This agent has many tools. Use on-demand selection or keep only the tools needed for one concrete task.',
|
||||
'agents.drawer.finding.similar_tools': 'Tools “{left}” and “{right}” look similar. Rename them more precisely or keep one of them.',
|
||||
'agents.drawer.filter_ops': 'Filter operations…',
|
||||
'agents.drawer.ops_no_match': 'No operations match "{query}"',
|
||||
'agents.drawer.ops_selected': '{count} operations selected',
|
||||
'agents.drawer.clear_all': 'Clear all',
|
||||
'agents.drawer.recommendation': "You've selected {count} tools. LLMs usually work best when an agent has fewer than 15 tools. Consider splitting this into separate agents by use case.",
|
||||
'agents.drawer.recommendation': "You've selected {count} tools. Switch to on-demand selection so the model receives only relevant schemas.",
|
||||
'agents.drawer.access.title': 'Tool access',
|
||||
'agents.drawer.access.subtitle': "Choose how the model receives this agent's tool catalog.",
|
||||
'agents.drawer.access.direct': 'Show tools immediately',
|
||||
'agents.drawer.access.direct_hint': 'Best for a small curated catalog. MCP clients receive every tool in tools/list.',
|
||||
'agents.drawer.access.search': 'Select tools on demand',
|
||||
'agents.drawer.access.search_hint': 'The model sees search_tools and call_tool, then discovers only relevant schemas.',
|
||||
'agents.drawer.groups.title': 'Catalog sections',
|
||||
'agents.drawer.groups.subtitle': 'Sections help the model narrow a search without blocking catalog-wide discovery.',
|
||||
'agents.drawer.groups.add': 'Add section',
|
||||
'agents.drawer.groups.empty': 'No sections yet. Search will use the entire selected catalog.',
|
||||
'agents.drawer.groups.untitled': 'Untitled section',
|
||||
'agents.drawer.groups.remove': 'Remove section',
|
||||
'agents.drawer.groups.name': 'Name',
|
||||
'agents.drawer.groups.name_placeholder': 'Finance',
|
||||
'agents.drawer.groups.id': 'Identifier',
|
||||
'agents.drawer.groups.description': 'Description for the model',
|
||||
'agents.drawer.groups.description_placeholder': 'Invoices, payments and refunds',
|
||||
'agents.drawer.groups.assign': 'Assign tools to sections',
|
||||
'agents.drawer.search.limit': 'Maximum results per search',
|
||||
'agents.drawer.search.preview_title': 'Test tool selection',
|
||||
'agents.drawer.search.preview_subtitle': 'Enter a task and verify which tools the model will receive.',
|
||||
'agents.drawer.search.query_placeholder': 'Create an invoice for a customer',
|
||||
'agents.drawer.search.all_groups': 'All sections',
|
||||
'agents.drawer.search.test': 'Test',
|
||||
'agents.drawer.search.testing': 'Testing…',
|
||||
'agents.drawer.search.no_results': 'No preview results yet.',
|
||||
'agents.drawer.search.preview_error': 'Failed to test tool selection',
|
||||
'agents.drawer.search.preview_error_title': 'Tool selection test failed',
|
||||
'agents.drawer.cancel': 'Cancel',
|
||||
'agents.drawer.create': 'Create agent',
|
||||
'agents.drawer.save': 'Save changes',
|
||||
@@ -905,6 +985,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 +1014,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 +1034,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 +1062,7 @@ var TRANSLATIONS = {
|
||||
'apikeys.action.revoke': 'Отозвать ключ',
|
||||
'apikeys.action.delete': 'Удалить',
|
||||
'apikeys.creating': 'Создание…',
|
||||
'apikeys.approval.warning': 'Не передавайте этот ключ LLM или MCP-клиенту. Он нужен только внешнему интерфейсу, где человек подтверждает действие.',
|
||||
|
||||
// Secrets page
|
||||
'secrets.title': 'Секреты',
|
||||
@@ -1080,6 +1173,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': 'Использование',
|
||||
@@ -1220,8 +1334,8 @@ var TRANSLATIONS = {
|
||||
'workspace_setup.create.subtitle': 'В Community используется один воркспейс для MCP-операций и агентов.',
|
||||
'workspace_setup.create.footer': 'В Community используется один воркспейс.',
|
||||
'workspace_setup.danger.title': 'Опасная зона',
|
||||
'workspace_setup.danger.export_title': 'Экспортировать все данные',
|
||||
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, операций, агентов, секретов, данных использования и ключей доступа агентов.',
|
||||
'workspace_setup.danger.export_title': 'Экспорт каталога рабочего пространства',
|
||||
'workspace_setup.danger.export_body': 'Скачать невосстанавливаемый JSON-каталог настроек рабочего пространства, сводок операций и агентов, а также метаданных ключей API.',
|
||||
'workspace_setup.export': 'Экспорт',
|
||||
'workspace_setup.role.owner': 'Владелец',
|
||||
'workspace_setup.role.admin': 'Администратор',
|
||||
@@ -1372,6 +1486,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': 'Проверка и публикация',
|
||||
@@ -1579,13 +1711,41 @@ var TRANSLATIONS = {
|
||||
'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
||||
'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
||||
'agents.drawer.finding.title': 'Рекомендация',
|
||||
'agents.drawer.finding.too_many_tools': 'У агента выбрано много инструментов. Оставьте только те, которые нужны для одной конкретной задачи.',
|
||||
'agents.drawer.finding.too_many_tools': 'У агента выбрано много инструментов. Включите подбор по запросу или оставьте только инструменты для одной конкретной задачи.',
|
||||
'agents.drawer.finding.similar_tools': 'Инструменты «{left}» и «{right}» похожи. Переименуйте их точнее или оставьте один вариант.',
|
||||
'agents.drawer.filter_ops': 'Фильтр операций…',
|
||||
'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"',
|
||||
'agents.drawer.ops_selected': 'Выбрано операций: {count}',
|
||||
'agents.drawer.clear_all': 'Очистить все',
|
||||
'agents.drawer.recommendation': 'Сейчас выбрано {count} инструментов. LLM лучше работает, когда у агента меньше 15 инструментов. Подумайте о разбиении по сценариям.',
|
||||
'agents.drawer.recommendation': 'Сейчас выбрано {count} инструментов. Включите подбор по запросу, чтобы модель получала только подходящие схемы.',
|
||||
'agents.drawer.access.title': 'Доступ к инструментам',
|
||||
'agents.drawer.access.subtitle': 'Выберите, как модель будет получать каталог инструментов этого агента.',
|
||||
'agents.drawer.access.direct': 'Показывать сразу',
|
||||
'agents.drawer.access.direct_hint': 'Для небольшого отобранного каталога. MCP-клиент получает все инструменты через tools/list.',
|
||||
'agents.drawer.access.search': 'Подбирать по запросу',
|
||||
'agents.drawer.access.search_hint': 'Модель видит search_tools и call_tool, а затем получает только подходящие схемы.',
|
||||
'agents.drawer.groups.title': 'Разделы каталога',
|
||||
'agents.drawer.groups.subtitle': 'Разделы сужают область поиска, но не мешают искать по всему каталогу.',
|
||||
'agents.drawer.groups.add': 'Добавить раздел',
|
||||
'agents.drawer.groups.empty': 'Разделов пока нет. Поиск будет выполняться по всему выбранному каталогу.',
|
||||
'agents.drawer.groups.untitled': 'Раздел без названия',
|
||||
'agents.drawer.groups.remove': 'Удалить раздел',
|
||||
'agents.drawer.groups.name': 'Название',
|
||||
'agents.drawer.groups.name_placeholder': 'Расчёты',
|
||||
'agents.drawer.groups.id': 'Идентификатор',
|
||||
'agents.drawer.groups.description': 'Описание для модели',
|
||||
'agents.drawer.groups.description_placeholder': 'Счета, платежи и возвраты',
|
||||
'agents.drawer.groups.assign': 'Распределение инструментов по разделам',
|
||||
'agents.drawer.search.limit': 'Максимум результатов за один поиск',
|
||||
'agents.drawer.search.preview_title': 'Проверка подбора',
|
||||
'agents.drawer.search.preview_subtitle': 'Введите задачу и проверьте, какие инструменты получит модель.',
|
||||
'agents.drawer.search.query_placeholder': 'Создать счёт для клиента',
|
||||
'agents.drawer.search.all_groups': 'Все разделы',
|
||||
'agents.drawer.search.test': 'Проверить',
|
||||
'agents.drawer.search.testing': 'Проверяем…',
|
||||
'agents.drawer.search.no_results': 'Результатов проверки пока нет.',
|
||||
'agents.drawer.search.preview_error': 'Не удалось проверить подбор инструментов',
|
||||
'agents.drawer.search.preview_error_title': 'Ошибка проверки подбора',
|
||||
'agents.drawer.cancel': 'Отмена',
|
||||
'agents.drawer.create': 'Создать агента',
|
||||
'agents.drawer.save': 'Сохранить изменения',
|
||||
|
||||
+206
-6
@@ -8,12 +8,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
openId: null,
|
||||
liveMode: true,
|
||||
timer: null,
|
||||
searchTimer: null,
|
||||
refreshPromise: null,
|
||||
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 +61,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 +131,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 +429,45 @@ 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() {
|
||||
if (state.refreshPromise) {
|
||||
return state.refreshPromise;
|
||||
}
|
||||
state.refreshPromise = Promise.all([loadLogs(), loadApprovals()]).finally(function () {
|
||||
state.refreshPromise = null;
|
||||
});
|
||||
return state.refreshPromise;
|
||||
}
|
||||
|
||||
async function loadLogDetail(logId) {
|
||||
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
|
||||
return;
|
||||
@@ -340,10 +502,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
if (!state.liveMode) {
|
||||
if (!state.liveMode || document.hidden) {
|
||||
return;
|
||||
}
|
||||
state.timer = setInterval(loadLogs, 4000);
|
||||
state.timer = setTimeout(async function poll() {
|
||||
state.timer = null;
|
||||
await refreshOperationalData();
|
||||
startPolling();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function toggleLive() {
|
||||
@@ -372,7 +538,13 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (logSearch) {
|
||||
logSearch.addEventListener('input', function () {
|
||||
state.search = this.value.trim();
|
||||
loadLogs();
|
||||
if (state.searchTimer) {
|
||||
clearTimeout(state.searchTimer);
|
||||
}
|
||||
state.searchTimer = setTimeout(function () {
|
||||
state.searchTimer = null;
|
||||
loadLogs();
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -386,6 +558,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 +587,33 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
window.addEventListener('crank:workspacechange', function () {
|
||||
state.details = {};
|
||||
state.openId = null;
|
||||
loadLogs();
|
||||
refreshOperationalData();
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (state.liveMode) {
|
||||
refreshOperationalData().finally(startPolling);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', function () {
|
||||
stopPolling();
|
||||
if (state.searchTimer) {
|
||||
clearTimeout(state.searchTimer);
|
||||
state.searchTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
setLiveState();
|
||||
startPolling();
|
||||
|
||||
if (window.whenWorkspacesReady) {
|
||||
window.whenWorkspacesReady().finally(loadLogs);
|
||||
window.whenWorkspacesReady().finally(refreshOperationalData);
|
||||
} else {
|
||||
loadLogs();
|
||||
refreshOperationalData();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -330,6 +330,9 @@ async function loadWorkspaceSettings() {
|
||||
}
|
||||
|
||||
async function initSettingsPage() {
|
||||
document.querySelectorAll('.lang-btn[data-lang]').forEach(function(button) {
|
||||
button.addEventListener('click', function() { setLang(button.dataset.lang); });
|
||||
});
|
||||
bindSectionNavigation();
|
||||
await loadProfile();
|
||||
await loadCapabilities();
|
||||
|
||||
Vendored
-5
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -83,6 +83,7 @@ async function initWizardPage() {
|
||||
await loadProtocolCapabilities();
|
||||
|
||||
await loadWizardPanels([1, 2, 3, 4, 5]);
|
||||
bindWizardPanelActions();
|
||||
if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') {
|
||||
await window.CrankOverlay.render(document, {
|
||||
workspace: workspace,
|
||||
@@ -248,6 +249,36 @@ function selectMethod(btn) {
|
||||
}
|
||||
}
|
||||
|
||||
function bindWizardPanelActions() {
|
||||
var upstreamSearch = document.getElementById('upstream-search');
|
||||
var authMode = document.getElementById('new-upstream-auth-mode');
|
||||
var authKind = document.getElementById('new-auth-profile-kind');
|
||||
|
||||
if (upstreamSearch) {
|
||||
upstreamSearch.addEventListener('input', function(event) {
|
||||
filterUpstreams(event.target.value);
|
||||
});
|
||||
}
|
||||
if (authMode) authMode.addEventListener('change', updateUpstreamAuthUi);
|
||||
if (authKind) authKind.addEventListener('change', updateAuthProfileCreateUi);
|
||||
|
||||
document.querySelectorAll('.method-card[data-method]').forEach(function(button) {
|
||||
button.addEventListener('click', function() { selectMethod(button); });
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-wizard-action]').forEach(function(element) {
|
||||
element.addEventListener('click', function(event) {
|
||||
var action = element.dataset.wizardAction;
|
||||
if (action === 'toggle-upstream') toggleUpstreamDropdown(event);
|
||||
if (action === 'edit-upstream') beginEditSelectedUpstream(event);
|
||||
if (action === 'new-upstream') startNewUpstream();
|
||||
if (action === 'quick-secret') openQuickSecretModal(event);
|
||||
if (action === 'save-upstream') void saveNewUpstream(event);
|
||||
if (action === 'cancel-upstream') cancelNewUpstream(event);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ async function exportWorkspaceSnapshot() {
|
||||
var slug = workspaceFormState.workspaceRecord && workspaceFormState.workspaceRecord.workspace
|
||||
? workspaceFormState.workspaceRecord.workspace.slug
|
||||
: tKey('settings.nav.workspace');
|
||||
downloadJsonFile(slug + '-snapshot.json', snapshot);
|
||||
downloadJsonFile(slug + '-catalog-snapshot.json', snapshot);
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.export_error'), tKey('workspace_setup.export_error_title'));
|
||||
@@ -207,6 +207,17 @@ async function exportWorkspaceSnapshot() {
|
||||
async function initPage() {
|
||||
updatePageMode();
|
||||
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function(swatch) {
|
||||
swatch.addEventListener('click', function() { pickColor(swatch); });
|
||||
});
|
||||
document.querySelectorAll('[data-history-back]').forEach(function(button) {
|
||||
button.addEventListener('click', function() { window.history.back(); });
|
||||
});
|
||||
var elements = formElements();
|
||||
elements.name.addEventListener('input', function(event) { onWsNameInput(event.target.value); });
|
||||
elements.slug.addEventListener('input', function(event) { onWsSlugInput(event.target.value); });
|
||||
elements.submit.addEventListener('click', submitForm);
|
||||
|
||||
var exportButton = document.getElementById('export-workspace-btn');
|
||||
if (exportButton) {
|
||||
exportButton.addEventListener('click', exportWorkspaceSnapshot);
|
||||
|
||||
@@ -6,6 +6,13 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
Generated
+159
-129
@@ -6,18 +6,20 @@
|
||||
"": {
|
||||
"name": "crank-ui",
|
||||
"dependencies": {
|
||||
"alpinejs": "3.15.9",
|
||||
"js-yaml": "4.1.1"
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@fontsource/jetbrains-mono": "5.2.8",
|
||||
"alpinejs": "3.15.12",
|
||||
"js-yaml": "5.2.2"
|
||||
},
|
||||
"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 +34,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 +51,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 +68,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 +85,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 +102,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 +119,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 +136,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 +153,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 +170,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 +187,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 +204,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 +221,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 +238,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 +255,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 +272,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 +289,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 +306,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 +323,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 +340,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 +357,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 +374,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 +391,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 +408,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 +425,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 +442,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"
|
||||
],
|
||||
@@ -456,14 +458,32 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"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 +508,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 +523,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 +536,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 +580,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.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz",
|
||||
"integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==",
|
||||
"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 +621,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": {
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "3.15.9",
|
||||
"js-yaml": "4.1.1"
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@fontsource/jetbrains-mono": "5.2.8",
|
||||
"alpinejs": "3.15.12",
|
||||
"js-yaml": "5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"esbuild": "^0.28.0"
|
||||
"@playwright/test": "1.61.1",
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ const BRAND_IMAGE_PATHS = [
|
||||
path.join(ROOT_DIR, 'crank-community.png'),
|
||||
path.resolve(ROOT_DIR, '..', '..', 'crank-community.png'),
|
||||
];
|
||||
const FONT_FILES = [
|
||||
['@fontsource/inter', 'inter', ['400', '500', '600', '700']],
|
||||
['@fontsource/jetbrains-mono', 'jetbrains-mono', ['400', '500']],
|
||||
];
|
||||
|
||||
const BUNDLES = {
|
||||
'protected-core': {
|
||||
@@ -89,7 +93,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',
|
||||
@@ -136,6 +140,20 @@ function copyDirectory(source, destination) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyFonts() {
|
||||
FONT_FILES.forEach(function([packageName, family, weights]) {
|
||||
weights.forEach(function(weight) {
|
||||
['latin', 'cyrillic'].forEach(function(subset) {
|
||||
var fileName = `${family}-${subset}-${weight}-normal.woff2`;
|
||||
copyFile(
|
||||
path.join(ROOT_DIR, 'node_modules', packageName, 'files', fileName),
|
||||
path.join(DIST_DIR, 'fonts', fileName)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readSource(relativePath) {
|
||||
return fs.readFileSync(sourcePath(relativePath), 'utf8');
|
||||
}
|
||||
@@ -191,6 +209,7 @@ async function main() {
|
||||
}
|
||||
|
||||
copyDirectory(path.join(ROOT_DIR, 'css'), path.join(DIST_DIR, 'css'));
|
||||
copyFonts();
|
||||
copyDirectory(path.join(ROOT_DIR, 'data'), path.join(DIST_DIR, 'data'));
|
||||
ensureDirectory(path.join(DIST_DIR, 'html', 'wizard'));
|
||||
[
|
||||
|
||||
@@ -119,8 +119,18 @@ function proxyRequest(request, response) {
|
||||
},
|
||||
},
|
||||
(proxyResponse) => {
|
||||
if (response.destroyed || response.writableEnded) {
|
||||
proxyResponse.resume();
|
||||
return;
|
||||
}
|
||||
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
||||
pipeline(proxyResponse, response, () => {});
|
||||
proxyResponse.on('error', function() {
|
||||
if (!response.destroyed) response.destroy();
|
||||
});
|
||||
response.on('close', function() {
|
||||
if (!proxyResponse.destroyed) proxyResponse.destroy();
|
||||
});
|
||||
proxyResponse.pipe(response);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -21,3 +21,26 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
|
||||
);
|
||||
await expect(page.locator('.drawer')).toContainText(/mcp/i);
|
||||
});
|
||||
|
||||
test('agent drawer configures on-demand tool discovery and catalog sections', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/agents');
|
||||
await page.getByRole('button', { name: localized('New agent', 'Новый агент') }).click();
|
||||
await page.locator('.ops-picker-item').first().click();
|
||||
|
||||
await page.locator('.tool-access-option').nth(1).click();
|
||||
await expect(page.locator('.tool-search-config')).toBeVisible();
|
||||
await page.getByRole('button', { name: localized('Add section', 'Добавить раздел') }).click();
|
||||
|
||||
const group = page.locator('.tool-group-card').first();
|
||||
await group.locator('input').nth(0).fill('Finance');
|
||||
await group.locator('input').nth(1).fill('finance');
|
||||
await group.locator('textarea').fill('Invoices, payments and refunds');
|
||||
|
||||
await expect(group.locator('input').nth(1)).toHaveValue('finance');
|
||||
await expect(page.locator('.tool-search-preview')).toBeVisible();
|
||||
await page.locator('.tool-group-chip').first().click();
|
||||
await page.locator('.tool-search-preview input').fill('currency rate');
|
||||
await page.locator('.tool-search-preview .btn-primary-sm').click();
|
||||
await expect(page.locator('.tool-search-result').first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -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_');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { getCurrentWorkspace, login, localized } = require('./helpers');
|
||||
|
||||
test('mobile wizard progress connector crosses the indicator centers', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 720, height: 900 });
|
||||
await login(page);
|
||||
await page.goto('/wizard/');
|
||||
|
||||
const geometry = await page.locator('.steps-list').evaluate((list) => {
|
||||
const indicators = [...list.querySelectorAll('.step-indicator')];
|
||||
const first = indicators[0].getBoundingClientRect();
|
||||
const last = indicators.at(-1).getBoundingClientRect();
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const line = getComputedStyle(list, '::before');
|
||||
return {
|
||||
leftDelta: Math.abs(listRect.left + Number.parseFloat(line.left) - (first.left + first.width / 2)),
|
||||
rightDelta: Math.abs(listRect.right - Number.parseFloat(line.right) - (last.left + last.width / 2)),
|
||||
topDelta: Math.abs(listRect.top + Number.parseFloat(line.top) - (first.top + first.height / 2)),
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.leftDelta).toBeLessThan(1);
|
||||
expect(geometry.rightDelta).toBeLessThan(1);
|
||||
expect(geometry.topDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/wizard/');
|
||||
@@ -138,15 +161,15 @@ test('wizard builds visual request mappings from JSON sample and path params', a
|
||||
|
||||
await page.goto('/wizard/');
|
||||
await page.locator('[data-testid="wizard-protocol-rest"]').click();
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(2));
|
||||
await page.evaluate(() => window.CrankWizardShell.goToStep(2));
|
||||
await expect(page.locator('#step-panel-2')).toBeVisible();
|
||||
await page.locator('#endpoint-path').fill('/rates/{date}');
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(3));
|
||||
await page.evaluate(() => window.CrankWizardShell.goToStep(3));
|
||||
await expect(page.locator('#step-panel-3-rest')).toBeVisible();
|
||||
await page.locator('.method-card[data-method="GET"]').click();
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
|
||||
await page.evaluate(() => window.CrankWizardShell.goToStep(5));
|
||||
await expect(page.locator('#step-panel-5')).toBeVisible();
|
||||
|
||||
await page.locator('#wizard-input-sample').fill(JSON.stringify({
|
||||
@@ -550,6 +573,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 +795,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 +847,13 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/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 +879,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: 'Получить последний курс',
|
||||
|
||||
@@ -11,6 +11,12 @@ test('workspace and settings pages show live session data', async ({ page }) =>
|
||||
await expect(page.locator('#section-members')).toHaveCount(0);
|
||||
await expect(page.locator('#section-invite')).toHaveCount(0);
|
||||
await expect(page.locator('#delete-workspace-btn')).toHaveCount(0);
|
||||
await expect(page.locator('[data-i18n="workspace_setup.danger.export_title"]')).toHaveText(
|
||||
localized('Export workspace catalog', 'Экспорт каталога рабочего пространства'),
|
||||
);
|
||||
await expect(page.locator('[data-i18n="workspace_setup.danger.export_body"]')).not.toContainText(
|
||||
localized('all data', 'все данные'),
|
||||
);
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.locator('.page-title')).toHaveText(localized('Account settings', 'Настройки аккаунта'));
|
||||
|
||||
@@ -3,18 +3,26 @@ name = "crank-adapter-rest"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
crank-core = { path = "../crank-core" }
|
||||
crank-trace = { path = "../crank-trace" }
|
||||
futures-util = "0.3"
|
||||
metrics.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
axum.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
use std::{collections::BTreeMap, time::Duration};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, io,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crank_core::{HttpMethod, RestTarget};
|
||||
use crank_trace::{ErrorCategory, Stage, StageOutcome};
|
||||
use futures_util::StreamExt;
|
||||
use opentelemetry::{global, propagation::Injector, trace::TraceContextExt};
|
||||
use reqwest::{
|
||||
Client,
|
||||
dns::{Addrs, Name, Resolve, Resolving},
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
redirect,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing::{Instrument, Span};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
use crate::{RestAdapterError, RestRequest, RestResponse};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RestAdapter {
|
||||
client: Client,
|
||||
client: Result<Client, Arc<str>>,
|
||||
policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OutboundHttpPolicy {
|
||||
allowed_hosts: Vec<String>,
|
||||
denied_hosts: Vec<String>,
|
||||
max_response_bytes: usize,
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
impl Default for RestAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -22,20 +45,69 @@ impl Default for RestAdapter {
|
||||
|
||||
impl RestAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
Self::with_policy(OutboundHttpPolicy::default())
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||
Ok(Self::with_policy(OutboundHttpPolicy::from_env()?))
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: OutboundHttpPolicy) -> Self {
|
||||
let resolver = Arc::new(PolicyDnsResolver {
|
||||
policy: policy.clone(),
|
||||
});
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.dns_resolver(resolver)
|
||||
.build()
|
||||
.map_err(|error| Arc::<str>::from(error.to_string()));
|
||||
|
||||
Self { client, policy }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
target: &RestTarget,
|
||||
request: &RestRequest,
|
||||
) -> Result<RestResponse, RestAdapterError> {
|
||||
let started_at = std::time::Instant::now();
|
||||
let result = self.execute_inner(target, request).await;
|
||||
let outcome = match &result {
|
||||
Ok(_) => "success",
|
||||
Err(error) => upstream_outcome(error),
|
||||
};
|
||||
metrics::counter!(
|
||||
"crank_upstream_requests_total",
|
||||
"operation_kind" => "rest",
|
||||
"outcome" => outcome
|
||||
)
|
||||
.increment(1);
|
||||
metrics::histogram!(
|
||||
"crank_upstream_request_duration_seconds",
|
||||
"operation_kind" => "rest",
|
||||
"outcome" => outcome
|
||||
)
|
||||
.record(started_at.elapsed().as_secs_f64());
|
||||
result
|
||||
}
|
||||
|
||||
async fn execute_inner(
|
||||
&self,
|
||||
target: &RestTarget,
|
||||
request: &RestRequest,
|
||||
) -> Result<RestResponse, RestAdapterError> {
|
||||
let url = build_url(target, request)?;
|
||||
let headers = build_headers(target, request)?;
|
||||
let mut builder = self
|
||||
.client
|
||||
self.policy.validate_url(&url)?;
|
||||
let mut headers = build_headers(target, request)?;
|
||||
apply_current_trace_context(&mut headers);
|
||||
let client =
|
||||
self.client
|
||||
.as_ref()
|
||||
.map_err(|details| RestAdapterError::InvalidConfiguration {
|
||||
details: details.to_string(),
|
||||
})?;
|
||||
let mut builder = client
|
||||
.request(to_reqwest_method(target.method), url)
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_millis(request.timeout_ms));
|
||||
@@ -44,24 +116,309 @@ impl RestAdapter {
|
||||
builder = builder.json(body);
|
||||
}
|
||||
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
let headers = normalize_headers(response.headers());
|
||||
let body = decode_body(response).await?;
|
||||
let upstream_span = Stage::UpstreamHttp.span();
|
||||
let result = async {
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
let headers = normalize_headers(response.headers());
|
||||
let body = decode_body(response, self.policy.max_response_bytes).await?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(RestAdapterError::UnexpectedStatus {
|
||||
status: status.as_u16(),
|
||||
if !status.is_success() {
|
||||
return Err(RestAdapterError::UnexpectedStatus {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RestResponse {
|
||||
status_code: status.as_u16(),
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
.instrument(upstream_span.clone())
|
||||
.await;
|
||||
match &result {
|
||||
Ok(_) => StageOutcome::Success.record(&upstream_span),
|
||||
Err(_) => {
|
||||
StageOutcome::Error.record(&upstream_span);
|
||||
ErrorCategory::Upstream.record(&upstream_span);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_outcome(error: &RestAdapterError) -> &'static str {
|
||||
match error {
|
||||
RestAdapterError::UnexpectedStatus { status, .. } if (400..500).contains(status) => {
|
||||
"client_error"
|
||||
}
|
||||
RestAdapterError::UnexpectedStatus { status, .. } if (500..600).contains(status) => {
|
||||
"server_error"
|
||||
}
|
||||
RestAdapterError::UnexpectedStatus { .. } => "unexpected_status",
|
||||
RestAdapterError::Transport(error) if error.is_timeout() => "timeout",
|
||||
RestAdapterError::Transport(_) => "transport_error",
|
||||
RestAdapterError::ResponseTooLarge { .. } => "response_too_large",
|
||||
RestAdapterError::TargetNotAllowed { .. } => "rejected",
|
||||
RestAdapterError::WindowExpired => "window_expired",
|
||||
RestAdapterError::InvalidSseEvent => "invalid_response",
|
||||
RestAdapterError::InvalidBaseUrl { .. }
|
||||
| RestAdapterError::InvalidPathParameter { .. }
|
||||
| RestAdapterError::InvalidQueryParameter { .. }
|
||||
| RestAdapterError::InvalidHeaderName { .. }
|
||||
| RestAdapterError::InvalidHeaderValue { .. } => "invalid_request",
|
||||
RestAdapterError::InvalidConfiguration { .. } => "configuration",
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OutboundHttpPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_hosts: Vec::new(),
|
||||
denied_hosts: Vec::new(),
|
||||
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutboundHttpPolicy {
|
||||
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||
let max_response_bytes = match env::var("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") {
|
||||
Ok(value) => {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| RestAdapterError::InvalidConfiguration {
|
||||
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be a positive integer"
|
||||
.to_owned(),
|
||||
})?
|
||||
}
|
||||
Err(env::VarError::NotPresent) => DEFAULT_MAX_RESPONSE_BYTES,
|
||||
Err(error) => {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if max_response_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RestResponse {
|
||||
status_code: status.as_u16(),
|
||||
headers,
|
||||
body,
|
||||
Ok(Self {
|
||||
allowed_hosts: host_patterns_from_env("CRANK_OUTBOUND_ALLOWED_HOSTS")?,
|
||||
denied_hosts: host_patterns_from_env("CRANK_OUTBOUND_DENIED_HOSTS")?,
|
||||
max_response_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn allowing_hosts(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
Self {
|
||||
allowed_hosts: hosts.into_iter().map(Into::into).collect(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
|
||||
self.max_response_bytes = max_response_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate_base_url(&self, base_url: &str) -> Result<(), RestAdapterError> {
|
||||
let url = reqwest::Url::parse(base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
url: base_url.to_owned(),
|
||||
})?;
|
||||
self.validate_url(&url)
|
||||
}
|
||||
|
||||
fn validate_url(&self, url: &reqwest::Url) -> Result<(), RestAdapterError> {
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
});
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
})?;
|
||||
self.validate_host(host)?;
|
||||
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
});
|
||||
}
|
||||
if let Ok(address) = host.parse::<IpAddr>()
|
||||
&& !self.is_explicitly_allowed(host)
|
||||
&& !is_public_ip(address)
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_host(&self, host: &str) -> Result<(), RestAdapterError> {
|
||||
let host = normalize_host(host);
|
||||
let denied = self
|
||||
.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host));
|
||||
if denied {
|
||||
return Err(RestAdapterError::TargetNotAllowed { target: host });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_explicitly_allowed(&self, host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
self.allowed_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PolicyDnsResolver {
|
||||
policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
impl Resolve for PolicyDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = normalize_host(name.as_str());
|
||||
let policy = self.policy.clone();
|
||||
Box::pin(async move {
|
||||
policy
|
||||
.validate_host(&host)
|
||||
.map_err(|error| boxed_io_error(error.to_string()))?;
|
||||
let explicitly_allowed = policy.is_explicitly_allowed(&host);
|
||||
let resolved = tokio::net::lookup_host((host.as_str(), 0))
|
||||
.await
|
||||
.map_err(|error| Box::new(error) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||
let addresses = resolved
|
||||
.filter(|address| explicitly_allowed || is_public_ip(address.ip()))
|
||||
.collect::<Vec<SocketAddr>>();
|
||||
if addresses.is_empty() {
|
||||
return Err(boxed_io_error(format!(
|
||||
"outbound target {host} did not resolve to an allowed address"
|
||||
)));
|
||||
}
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed_io_error(message: String) -> Box<dyn std::error::Error + Send + Sync> {
|
||||
Box::new(io::Error::new(io::ErrorKind::PermissionDenied, message))
|
||||
}
|
||||
|
||||
fn host_patterns_from_env(name: &str) -> Result<Vec<String>, RestAdapterError> {
|
||||
let value = match env::var(name) {
|
||||
Ok(value) => value,
|
||||
Err(env::VarError::NotPresent) => return Ok(Vec::new()),
|
||||
Err(error) => {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
let wildcard = value.starts_with("*.");
|
||||
let normalized = normalize_host(value.trim_start_matches("*."));
|
||||
let valid_ip = !wildcard && normalized.parse::<IpAddr>().is_ok();
|
||||
if normalized.is_empty()
|
||||
|| normalized.contains('/')
|
||||
|| (!valid_ip && normalized.contains(':'))
|
||||
|| (wildcard && normalized.parse::<IpAddr>().is_ok())
|
||||
{
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: format!("{name} contains an invalid host pattern: {value}"),
|
||||
});
|
||||
}
|
||||
Ok(if wildcard {
|
||||
format!("*.{normalized}")
|
||||
} else {
|
||||
normalized
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_host(host: &str) -> String {
|
||||
host.trim()
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_local_hostname(host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
host == "localhost" || host.ends_with(".localhost")
|
||||
}
|
||||
|
||||
fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
pattern.strip_prefix("*.").map_or_else(
|
||||
|| pattern == host,
|
||||
|suffix| host != suffix && host.ends_with(&format!(".{suffix}")),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_public_ip(address: IpAddr) -> bool {
|
||||
match address {
|
||||
IpAddr::V4(address) => is_public_ipv4(address),
|
||||
IpAddr::V6(address) => is_public_ipv6(address),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(address: Ipv4Addr) -> bool {
|
||||
let octets = address.octets();
|
||||
!(address.is_private()
|
||||
|| address.is_loopback()
|
||||
|| address.is_link_local()
|
||||
|| address.is_broadcast()
|
||||
|| address.is_documentation()
|
||||
|| address.is_unspecified()
|
||||
|| address.is_multicast()
|
||||
|| octets[0] == 0
|
||||
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|
||||
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|
||||
|| (octets[0] == 198 && (18..=19).contains(&octets[1]))
|
||||
|| octets[0] >= 240)
|
||||
}
|
||||
|
||||
fn is_public_ipv6(address: Ipv6Addr) -> bool {
|
||||
let segments = address.segments();
|
||||
if let Some(address) = address.to_ipv4_mapped() {
|
||||
return is_public_ipv4(address);
|
||||
}
|
||||
if segments[..6].iter().all(|segment| *segment == 0) {
|
||||
let [a, b] = segments[6].to_be_bytes();
|
||||
let [c, d] = segments[7].to_be_bytes();
|
||||
return is_public_ipv4(Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
!(address.is_unspecified()
|
||||
|| address.is_loopback()
|
||||
|| address.is_multicast()
|
||||
|| (segments[0] & 0xfe00) == 0xfc00
|
||||
|| (segments[0] & 0xffc0) == 0xfe80
|
||||
|| (segments[0] & 0xffc0) == 0xfec0
|
||||
|| (segments[0] == 0x0064
|
||||
&& segments[1] == 0xff9b
|
||||
&& segments[2..6].iter().all(|segment| *segment == 0))
|
||||
|| (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1)
|
||||
|| segments[0] == 0x2002
|
||||
|| (segments[0] == 0x2001 && matches!(segments[1], 0 | 0x0db8)))
|
||||
}
|
||||
|
||||
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
||||
@@ -118,6 +475,9 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
|
||||
HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName {
|
||||
header: name.to_owned(),
|
||||
})?;
|
||||
if is_trace_propagation_header(&header_name) {
|
||||
return Ok(());
|
||||
}
|
||||
let header_value =
|
||||
HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue {
|
||||
header: name.to_owned(),
|
||||
@@ -127,8 +487,61 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn decode_body(response: reqwest::Response) -> Result<Value, RestAdapterError> {
|
||||
let bytes = response.bytes().await?;
|
||||
fn is_trace_propagation_header(name: &HeaderName) -> bool {
|
||||
matches!(name.as_str(), "traceparent" | "tracestate" | "baggage")
|
||||
}
|
||||
|
||||
fn apply_current_trace_context(headers: &mut HeaderMap) {
|
||||
for header in ["traceparent", "tracestate", "baggage"] {
|
||||
headers.remove(header);
|
||||
}
|
||||
|
||||
let context = Span::current().context();
|
||||
if !context.span().span_context().is_valid() {
|
||||
return;
|
||||
}
|
||||
global::get_text_map_propagator(|propagator| {
|
||||
propagator.inject_context(&context, &mut ReqwestHeaderInjector(headers));
|
||||
});
|
||||
}
|
||||
|
||||
struct ReqwestHeaderInjector<'a>(&'a mut HeaderMap);
|
||||
|
||||
impl Injector for ReqwestHeaderInjector<'_> {
|
||||
fn set(&mut self, key: &str, value: String) {
|
||||
let Ok(name) = HeaderName::try_from(key) else {
|
||||
return;
|
||||
};
|
||||
let Ok(value) = HeaderValue::try_from(value) else {
|
||||
return;
|
||||
};
|
||||
self.0.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async fn decode_body(
|
||||
response: reqwest::Response,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Value, RestAdapterError> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_response_bytes as u64)
|
||||
{
|
||||
return Err(RestAdapterError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
if bytes.len().saturating_add(chunk.len()) > max_response_bytes {
|
||||
return Err(RestAdapterError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Ok(Value::Null);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user