13 Commits

Author SHA1 Message Date
bsodfather a02acf5db3 исправить: изолировать порты smoke-теста
CI / Rust Checks (pull_request) Successful in 5m54s
CI / UI Checks (pull_request) Successful in 6s
CI / Community Image Smoke (pull_request) Failing after 36s
CI / Frontend E2E (pull_request) Successful in 4m31s
CI / Deploy (pull_request) Has been skipped
2026-07-31 03:25:55 +03:00
bsodfather 9a7d60593a исправить: закрыть ревью сквозной корреляции
CI / Rust Checks (pull_request) Successful in 8m33s
CI / UI Checks (pull_request) Successful in 5s
CI / Frontend E2E (pull_request) Successful in 6m51s
CI / Community Image Smoke (pull_request) Failing after 9m10s
CI / Deploy (pull_request) Has been skipped
2026-07-31 03:01:51 +03:00
bsodfather 0e8f1ca03a наблюдаемость: завершить базовый контур Community
CI / Rust Checks (push) Failing after 4m28s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
2026-07-31 01:01:14 +03:00
bsodfather 99bd05c145 агенты: добавить поиск инструментов по каталогу 2026-07-21 13:12:46 +03:00
bsodfather 63f8ee333f наблюдаемость: измерять бюджет каталога MCP
CI / Rust Checks (push) Successful in 12m5s
CI / UI Checks (push) Successful in 9s
CI / Deployment Manifests (push) Successful in 6s
CI / Frontend E2E (push) Failing after 30s
CI / Deploy (push) Has been skipped
2026-07-21 01:59:09 +03:00
bsodfather 0241d186ea Выровнять шкалу шагов мастера
CI / Rust Checks (push) Successful in 6m17s
CI / UI Checks (push) Successful in 8s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m21s
CI / Deploy (push) Successful in 2m42s
2026-07-12 13:26:13 +03:00
bsodfather 46892ee61c Усилить безопасность веб-интерфейса
CI / Rust Checks (push) Successful in 5m14s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 2m58s
CI / Deploy (push) Successful in 1m44s
2026-07-11 17:12:50 +03:00
bsodfather 8318e4b560 Усилить безопасность и надёжность выполнения операций
CI / Rust Checks (push) Successful in 5m7s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m9s
CI / Deploy (push) Successful in 1m41s
2026-07-11 14:08:07 +03:00
bsodfather 626f2845e2 Усилить проверку источника и ограничение запросов
CI / Rust Checks (push) Successful in 5m7s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m5s
CI / Deploy (push) Successful in 1m37s
2026-07-11 10:54:17 +03:00
bsodfather 502e339809 Адаптировать wizard под мобильный экран
CI / Rust Checks (push) Successful in 6m25s
CI / UI Checks (push) Successful in 8s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m29s
CI / Deploy (push) Successful in 2m44s
2026-07-10 01:05:12 +03:00
bsodfather dca97bd69b Включать мобильное меню раньше
CI / Rust Checks (push) Successful in 5m26s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 3m7s
CI / Deploy (push) Successful in 2m58s
2026-07-09 00:43:30 +03:00
Codex 061873058e ci: install rust toolchain from project config
CI / Rust Checks (push) Successful in 6m43s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m49s
CI / Deploy (push) Successful in 3m11s
2026-07-06 20:09:26 +00:00
bsodfather c98c7c8ce2 Merge pull request 'Обновил зависимости проекта' (#1) from chore/update-dependencies into main
CI / Rust Checks (push) Successful in 5m10s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m6s
CI / Deploy (push) Successful in 31s
Reviewed-on: #1
2026-07-06 19:49:54 +00:00
221 changed files with 18276 additions and 1802 deletions
+2
View File
@@ -0,0 +1,2 @@
[build]
jobs = 2
+31
View File
@@ -24,11 +24,42 @@ CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16 CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16 CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16 CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
# Публичные узлы разрешены по умолчанию. Для внутренних API перечислите
# допустимые имена или IP через запятую.
CRANK_OUTBOUND_ALLOWED_HOSTS=
CRANK_OUTBOUND_DENIED_HOSTS=
CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304
CRANK_ENVIRONMENT=development
CRANK_LOG_LEVEL=info 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_MASTER_KEY=change-me-master-key
CRANK_SESSION_SECRET=change-me-session-secret CRANK_SESSION_SECRET=change-me-session-secret
CRANK_PASSWORD_PEPPER=change-me-password-pepper CRANK_PASSWORD_PEPPER=change-me-password-pepper
CRANK_SESSION_TTL_HOURS=24 CRANK_SESSION_TTL_HOURS=24
# Trust X-Real-IP / X-Forwarded-For for client rate limiting. Enable only when
# admin-api runs behind the bundled nginx (or another trusted reverse proxy).
CRANK_TRUST_FORWARDED_HEADERS=true
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
+150 -58
View File
@@ -22,15 +22,21 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Use preinstalled Rust toolchain - name: Install Rust toolchain
run: | run: |
set -eu set -eu
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/1.96.1-x86_64-unknown-linux-gnu" 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" toolchain_bin="$toolchain_dir/bin"
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
echo "Rust 1.96.1 is not preinstalled at $toolchain_dir." >&2 echo "Rust $toolchain was not installed 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 exit 1
fi fi
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH" printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
@@ -49,6 +55,9 @@ jobs:
docker --version docker --version
docker info docker info
- name: Install dependency policy tool
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Run tooling unit tests - name: Run tooling unit tests
run: python3 -m unittest discover -s tests/unit run: python3 -m unittest discover -s tests/unit
@@ -61,6 +70,9 @@ jobs:
- name: Check Rust code health - name: Check Rust code health
run: scripts/check-rust-code-health.sh 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 - name: Check Rust boundaries
run: scripts/check-rust-boundaries.sh run: scripts/check-rust-boundaries.sh
@@ -89,6 +101,10 @@ jobs:
working-directory: apps/ui working-directory: apps/ui
run: npm ci run: npm ci
- name: Audit UI dependencies
working-directory: apps/ui
run: npm audit --audit-level=high
- name: Build UI bundle - name: Build UI bundle
working-directory: apps/ui working-directory: apps/ui
run: npm run build run: npm run build
@@ -121,15 +137,21 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Use preinstalled Rust toolchain - name: Install Rust toolchain
run: | run: |
set -eu set -eu
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/1.96.1-x86_64-unknown-linux-gnu" 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" toolchain_bin="$toolchain_dir/bin"
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
echo "Rust 1.96.1 is not preinstalled at $toolchain_dir." >&2 echo "Rust $toolchain was not installed 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 exit 1
fi fi
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH" printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
@@ -167,9 +189,11 @@ jobs:
find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true
deployment: deployment:
name: Deployment Manifests name: Community Image Smoke
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ui needs:
- rust
- ui
steps: steps:
- name: Checkout - name: Checkout
@@ -178,6 +202,65 @@ jobs:
- name: Validate Community deployment manifest - name: Validate Community deployment manifest
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q 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: deploy:
name: Deploy name: Deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -233,6 +316,10 @@ jobs:
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \ -t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.UI_IMAGE }}:main' \ -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 }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:main' docker push '${{ env.ADMIN_API_IMAGE }}:main'
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
@@ -262,9 +349,14 @@ jobs:
run: | run: |
. "$OPENBAO_ENV_FILE" . "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ 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 \ rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/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 - name: Write environment file
run: | run: |
@@ -280,6 +372,11 @@ jobs:
append_if_set POSTGRES_USER "$POSTGRES_USER" append_if_set POSTGRES_USER "$POSTGRES_USER"
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD" append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
append_if_set POSTGRES_HOST "$POSTGRES_HOST" 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 if [ -n "${POSTGRES_PORT:-}" ]; then
append_if_set POSTGRES_PORT "$POSTGRES_PORT" append_if_set POSTGRES_PORT "$POSTGRES_PORT"
elif [ -n "${PGBOUNCER_PORT:-}" ]; then elif [ -n "${PGBOUNCER_PORT:-}" ]; then
@@ -290,7 +387,35 @@ jobs:
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND" append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND" append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS" append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
append_if_set CRANK_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_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_MASTER_KEY "$CRANK_MASTER_KEY"
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL" append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND" append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
@@ -310,7 +435,10 @@ jobs:
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}' printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
} >> "$tmp_env" } >> "$tmp_env"
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ 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" rm -f "$tmp_env"
- name: Validate required environment variables - name: Validate required environment variables
@@ -344,48 +472,12 @@ jobs:
- name: Deploy with Docker Compose - name: Deploy with Docker Compose
run: | run: |
. "$OPENBAO_ENV_FILE" . "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" " printf '%s' "$DEPLOY_REGISTRY_TOKEN" | ssh -p "$DEPLOY_PORT" \
set -e "$DEPLOY_USER@$DEPLOY_HOST" \
cd '$DEPLOY_PATH' "docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin"
compose_profiles='' ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true) "chmod 700 '$DEPLOY_PATH/deploy-community.sh' && \
if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then '$DEPLOY_PATH/deploy-community.sh' '$DEPLOY_PATH'"
compose_profiles='--profile cache'
fi
echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin
docker compose \$compose_profiles config -q
docker compose \$compose_profiles pull
docker compose \$compose_profiles down --remove-orphans
for container in \
crank-ui-1 \
crank-admin-api-1 \
crank-mcp-server-1 \
crank-postgres-1 \
crank-valkey-1 \
crank-community-ui-1 \
crank-community-admin-api-1 \
crank-community-mcp-server-1 \
crank-community-postgres-1 \
crank-community-valkey-1; do
if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then
docker rm -f \"\$container\"
fi
done
echo 'Docker containers before freeing required ports:'
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'
for port in 3000 3001 3002; do
container_ids=\$(docker ps -aq --filter \"publish=\$port\")
if [ -n \"\$container_ids\" ]; then
echo \"Removing containers publishing port \$port\"
docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true
docker rm -f \$container_ids
fi
done
if command -v ss >/dev/null 2>&1; then
ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true
fi
docker compose \$compose_profiles up -d --remove-orphans
"
- name: Verify health endpoints - name: Verify health endpoints
run: | run: |
@@ -395,8 +487,8 @@ jobs:
cd '$DEPLOY_PATH' cd '$DEPLOY_PATH'
for attempt in \$(seq 1 30); do for attempt in \$(seq 1 30); do
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \ 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:3001/ready >/dev/null \
&& curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then && curl --fail --silent http://127.0.0.1:3002/ready >/dev/null; then
exit 0 exit 0
fi fi
sleep 2 sleep 2
+29
View File
@@ -49,6 +49,16 @@ jobs:
command -v bao command -v bao
bao version 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 - name: Build release binaries
run: cargo build --release -p admin-api -p mcp-server run: cargo build --release -p admin-api -p mcp-server
@@ -56,10 +66,25 @@ jobs:
working-directory: apps/ui working-directory: apps/ui
run: npm ci run: npm ci
- name: Audit UI dependencies
working-directory: apps/ui
run: npm audit --audit-level=high
- name: Build UI dist - name: Build UI dist
working-directory: apps/ui working-directory: apps/ui
run: npm run build 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 - name: Package release artifacts
run: | run: |
mkdir -p dist/release mkdir -p dist/release
@@ -105,6 +130,10 @@ jobs:
docker build -f apps/ui/Dockerfile \ docker build -f apps/ui/Dockerfile \
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \ -t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.UI_IMAGE }}:latest' . -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 }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:latest' docker push '${{ env.ADMIN_API_IMAGE }}:latest'
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
Generated
+798 -33
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -8,9 +8,11 @@ members = [
"crates/crank-import", "crates/crank-import",
"crates/crank-schema", "crates/crank-schema",
"crates/crank-mapping", "crates/crank-mapping",
"crates/crank-observability",
"crates/crank-registry", "crates/crank-registry",
"crates/crank-runtime", "crates/crank-runtime",
"crates/crank-test-support", "crates/crank-test-support",
"crates/crank-trace",
"crates/crank-adapter-rest", "crates/crank-adapter-rest",
] ]
resolver = "3" resolver = "3"
@@ -20,6 +22,7 @@ edition = "2024"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
rust-version = "1.96" rust-version = "1.96"
version = "0.3.1" version = "0.3.1"
publish = false
[workspace.dependencies] [workspace.dependencies]
aes-gcm = "0.10" aes-gcm = "0.10"
@@ -28,18 +31,43 @@ axum = "0.8"
axum-extra = { version = "0.12", features = ["cookie"] } axum-extra = { version = "0.12", features = ["cookie"] }
base64 = "0.22" base64 = "0.22"
hkdf = "0.12" hkdf = "0.12"
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" rand = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
sentry = { version = "0.49.0", default-features = false, features = ["backtrace", "panic", "rustls", "ureq"] }
sha2 = "0.10" sha2 = "0.10"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] } sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] }
subtle = "2.6"
thiserror = "2" thiserror = "2"
time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] } time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower = "0.5"
tracing = "0.1" tracing = "0.1"
tracing-opentelemetry = { version = "0.33.0", default-features = false }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2"
uuid = { version = "1", features = ["serde", "v7"] } uuid = { version = "1", features = ["serde", "v7"] }
testcontainers = { version = "0.27", features = ["blocking"] } testcontainers = { version = "0.27", features = ["blocking"] }
testcontainers-modules = { version = "0.15", features = ["postgres", "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
+8
View File
@@ -3,6 +3,7 @@ name = "admin-api"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true
version.workspace = true version.workspace = true
[[bin]] [[bin]]
@@ -18,9 +19,12 @@ crank-community-auth = { path = "../../crates/crank-community-auth" }
crank-core = { path = "../../crates/crank-core" } crank-core = { path = "../../crates/crank-core" }
crank-import = { path = "../../crates/crank-import" } crank-import = { path = "../../crates/crank-import" }
crank-mapping = { path = "../../crates/crank-mapping" } crank-mapping = { path = "../../crates/crank-mapping" }
crank-observability = { path = "../../crates/crank-observability" }
crank-registry = { path = "../../crates/crank-registry" } crank-registry = { path = "../../crates/crank-registry" }
crank-runtime = { path = "../../crates/crank-runtime" } crank-runtime = { path = "../../crates/crank-runtime" }
crank-schema = { path = "../../crates/crank-schema" } crank-schema = { path = "../../crates/crank-schema" }
crank-trace = { path = "../../crates/crank-trace" }
metrics.workspace = true
rand.workspace = true rand.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -37,5 +41,9 @@ uuid.workspace = true
[dev-dependencies] [dev-dependencies]
async-trait = "0.1" async-trait = "0.1"
crank-test-support = { path = "../../crates/crank-test-support" } crank-test-support = { path = "../../crates/crank-test-support" }
opentelemetry.workspace = true
opentelemetry_sdk.workspace = true
reqwest.workspace = true reqwest.workspace = true
serial_test = "3" serial_test = "3"
tower.workspace = true
tracing-opentelemetry.workspace = true
+6 -1
View File
@@ -12,7 +12,7 @@ use crate::{
agents::{ agents::{
archive_agent, create_agent, create_agent_platform_api_key, delete_agent, archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
delete_agent_platform_api_key, get_agent, get_agent_version, 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, revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
}, },
auth::{change_password, get_profile, get_session, login, logout, update_profile}, auth::{change_password, get_profile, get_session, login, logout, update_profile},
@@ -83,6 +83,7 @@ pub fn build_app(state: AppState) -> Router {
) )
.route("/operations/{operation_id}/export", get(export_operation)) .route("/operations/{operation_id}/export", get(export_operation))
.route("/agents", get(list_agents).post(create_agent)) .route("/agents", get(list_agents).post(create_agent))
.route("/agents/tool-search/preview", post(preview_tool_search))
.route( .route(
"/agents/{agent_id}", "/agents/{agent_id}",
get(get_agent).patch(update_agent).delete(delete_agent), get(get_agent).patch(update_agent).delete(delete_agent),
@@ -165,6 +166,7 @@ pub fn build_app(state: AppState) -> Router {
Router::new() Router::new()
.route("/health", get(crate::routes::health)) .route("/health", get(crate::routes::health))
.route("/ready", get(crate::routes::readiness))
.nest( .nest(
"/api/auth", "/api/auth",
Router::new() Router::new()
@@ -177,6 +179,9 @@ pub fn build_app(state: AppState) -> Router {
apply_api_rate_limit, apply_api_rate_limit,
)) ))
.layer(middleware::from_fn(apply_request_context)) .layer(middleware::from_fn(apply_request_context))
.layer(middleware::from_fn(
crank_observability::record_http_request,
))
.with_state(state) .with_state(state)
} }
+46 -3
View File
@@ -2,7 +2,7 @@ use crank_core::{
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus, ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
}; };
use crank_mapping::MappingSet; use crank_mapping::MappingSet;
use crank_registry::{ use crank_registry::{
@@ -144,7 +144,7 @@ pub struct AgentPayload {
#[serde(default)] #[serde(default)]
pub instructions: Value, pub instructions: Value,
#[serde(default)] #[serde(default)]
pub tool_selection_policy: Value, pub tool_selection_policy: ToolSelectionPolicy,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -165,6 +165,43 @@ pub struct AgentBindingPayload {
pub enabled: bool, 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)] #[derive(Clone, Debug, Serialize)]
pub struct CreatedAgentResponse { pub struct CreatedAgentResponse {
pub agent_id: String, pub agent_id: String,
@@ -196,6 +233,7 @@ pub struct AgentSummaryView {
pub published_at: Option<String>, pub published_at: Option<String>,
pub operation_count: usize, pub operation_count: usize,
pub operation_ids: Vec<String>, pub operation_ids: Vec<String>,
pub tool_selection_policy: ToolSelectionPolicy,
pub key_count: usize, pub key_count: usize,
pub calls_today: u64, pub calls_today: u64,
pub mcp_endpoint: String, pub mcp_endpoint: String,
@@ -231,7 +269,12 @@ pub struct CreatedPlatformApiKeyResponse {
} }
#[derive(Clone, Debug, Serialize)] #[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 workspace: WorkspaceRecord,
pub operations: Vec<OperationSummaryView>, pub operations: Vec<OperationSummaryView>,
pub agents: Vec<AgentSummaryView>, pub agents: Vec<AgentSummaryView>,
+30 -10
View File
@@ -137,16 +137,24 @@ impl ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match &self { match &self {
Self::Internal { message, .. } => { Self::Internal { .. } => {
error!(error_code = self.code(), error_message = %message) error!(
name: "admin.response.internal_error",
error_code = self.code(),
"internal API error response"
)
} }
Self::Unauthorized { message, .. } Self::Unauthorized { .. }
| Self::Forbidden { message, .. } | Self::Forbidden { .. }
| Self::Validation { message, .. } | Self::Validation { .. }
| Self::NotFound { message, .. } | Self::NotFound { .. }
| Self::Conflict { message, .. } | Self::Conflict { .. }
| Self::RateLimited { message, .. } => { | Self::RateLimited { .. } => {
warn!(error_code = self.code(), error_message = %message) 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"), format!("import job {job_id} was not found"),
json!({ "job_id": job_id }), 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(_) => { RegistryError::Storage(_) | RegistryError::Serialization(_) => {
Self::internal(value.to_string()) Self::internal(value.to_string())
} }
@@ -407,6 +419,10 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required", RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required",
RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error", RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error",
RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable", 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::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error", RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
@@ -434,7 +450,11 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
"safety_class": safety_class, "safety_class": safety_class,
})), })),
RuntimeError::InvalidConfirmationToken { operation_id } 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, "operation_id": operation_id,
})), })),
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({ RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
+112 -12
View File
@@ -1,4 +1,4 @@
use std::{env, net::SocketAddr, path::PathBuf}; use std::{env, net::SocketAddr, path::PathBuf, time::Duration};
use admin_api::{ use admin_api::{
app::build_app, app::build_app,
@@ -7,22 +7,50 @@ use admin_api::{
state::AppState, state::AppState,
}; };
use crank_community_auth::PasswordIdentityProvider; use crank_community_auth::PasswordIdentityProvider;
use crank_observability::{
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
capture_critical_error,
};
use crank_registry::{PostgresPoolConfig, PostgresRegistry}; use crank_registry::{PostgresPoolConfig, PostgresRegistry};
use crank_runtime::{ use crank_runtime::{
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
RuntimeLimits, SecretCrypto, community_default, RuntimeLimits, SecretCrypto,
}; };
use sqlx::postgres::PgConnectOptions; use sqlx::{PgPool, postgres::PgConnectOptions};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::info; use tracing::{info, warn};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt() let observability = crank_observability::init(ObservabilityConfig::from_env(
.with_env_filter( "admin-api",
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()), env!("CARGO_PKG_VERSION"),
) "admin_api=info,tower_http=info",
.init(); )?)?;
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( let storage_root = PathBuf::from(
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()), 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, pool_config,
) )
.await?; .await?;
if metrics_enabled {
spawn_postgres_pool_metrics(registry.pool().clone());
}
let auth_settings = AuthSettings { let auth_settings = AuthSettings {
session_secret: env::var("CRANK_SESSION_SECRET")?, session_secret: env::var("CRANK_SESSION_SECRET")?,
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?, 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 cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
let api_rate_limit = admin_api_rate_limit_config_from_env()?; let api_rate_limit = admin_api_rate_limit_config_from_env()?;
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
let runtime = community_default() let outbound_http_policy = crank_runtime::OutboundHttpPolicy::from_env()?;
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
.with_limits(runtime_limits) .with_limits(runtime_limits)
.with_response_cache(cache_stores.response.clone()) .with_response_cache(cache_stores.response.clone())
.with_coordination_store(cache_stores.coordination.clone()) .with_coordination_store(cache_stores.coordination.clone())
@@ -70,12 +102,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
secret_crypto, secret_crypto,
runtime, runtime,
) )
.with_outbound_http_policy(outbound_http_policy)
.with_identity_provider(std::sync::Arc::new(identity_provider)) .with_identity_provider(std::sync::Arc::new(identity_provider))
.build(); .build();
let invocation_log_retention_days =
positive_i64_from_env("CRANK_INVOCATION_LOG_RETENTION_DAYS", 30)?;
service.bootstrap_admin_user().await?; service.bootstrap_admin_user().await?;
if env_flag("CRANK_DEMO_SEED") { if env_flag("CRANK_DEMO_SEED") {
service.seed_demo_assets().await?; service.seed_demo_assets().await?;
} }
spawn_invocation_log_cleanup(service.clone(), invocation_log_retention_days);
let state = AppState { let state = AppState {
service, service,
api_rate_limiter: if cache_config.backend.is_external() { api_rate_limiter: if cache_config.backend.is_external() {
@@ -83,11 +119,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} else { } else {
RequestRateLimiter::new(api_rate_limit) RequestRateLimiter::new(api_rate_limit)
}, },
trust_forwarded_headers: env_flag("CRANK_TRUST_FORWARDED_HEADERS"),
}; };
let app = build_app(state); let app = build_app(state);
let listener = TcpListener::bind(socket_addr).await?; let listener = TcpListener::bind(socket_addr).await?;
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
info!( info!(
name: "admin.postgres_pool.configured",
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary, runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
admin_rate_limit_rps = api_rate_limit.requests_per_second, admin_rate_limit_rps = api_rate_limit.requests_per_second,
admin_rate_limit_burst = api_rate_limit.burst, 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, acquire_timeout_ms = pool_config.acquire_timeout_ms,
idle_timeout_ms = pool_config.idle_timeout_ms, idle_timeout_ms = pool_config.idle_timeout_ms,
max_lifetime_ms = pool_config.max_lifetime_ms, max_lifetime_ms = pool_config.max_lifetime_ms,
invocation_log_retention_days,
"postgres pool configured" "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(()) 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 { fn env_flag(name: &str) -> bool {
matches!( matches!(
env::var(name) env::var(name)
+134 -43
View File
@@ -1,24 +1,40 @@
use std::net::{IpAddr, SocketAddr};
use axum::{ use axum::{
extract::{Request, State}, extract::{ConnectInfo, Request, State},
http::header::{COOKIE, HeaderMap}, http::HeaderMap,
middleware::Next, middleware::Next,
response::Response, response::Response,
}; };
use crank_runtime::RateLimitRejection; use crank_runtime::{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( pub async fn apply_api_rate_limit(
State(state): State<AppState>, State(state): State<AppState>,
request: Request, request: Request,
next: Next, next: Next,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let key = rate_limit_key(request.headers(), request.uri().path()); let peer_ip = request
if let Err(rejection) = state.api_rate_limiter.check(&key).await { .extensions()
return Err(ApiError::rate_limited_with_context( .get::<ConnectInfo<SocketAddr>>()
"request rate limit exceeded", .map(|ConnectInfo(address)| address.ip());
rejection_context(rejection), 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) 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 { fn rate_limit_key(
if let Some(session_id) = session_id_from_headers(headers) { headers: &HeaderMap,
return format!("session:{session_id}"); path: &str,
peer_ip: Option<IpAddr>,
trust_forwarded_headers: bool,
) -> String {
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
return format!("ip:{client_ip}");
} }
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") { if let Some(peer_ip) = peer_ip {
let ip = forwarded_for return format!("ip:{peer_ip}");
.split(',')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown");
return format!("ip:{ip}");
}
if let Some(real_ip) = header_value(headers, "x-real-ip") {
return format!("ip:{real_ip}");
} }
format!("anonymous:{path}") format!("anonymous:{path}")
} }
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> { /// Resolves the client IP from proxy headers, assuming a single trusted proxy.
let cookies = headers.get(COOKIE)?.to_str().ok()?; ///
for part in cookies.split(';') { /// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
let (name, value) = part.trim().split_once('=')?; /// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
if name != SESSION_COOKIE_NAME { /// peer, so the last entry is the trustworthy hop; taking the first entry (as
continue; /// naive implementations do) would let a client spoof its address by sending a
} /// pre-populated header.
let (session_id, _) = value.split_once('.')?; fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
if !session_id.is_empty() { if let Some(real_ip) = header_value(headers, "x-real-ip").and_then(parse_ip) {
return Some(session_id.to_owned()); return Some(real_ip);
}
} }
None header_value(headers, "x-forwarded-for")?
.split(',')
.map(str::trim)
.rfind(|value| !value.is_empty())
.and_then(parse_ip)
} }
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
headers.get(name)?.to_str().ok().map(str::trim) headers.get(name)?.to_str().ok().map(str::trim)
} }
fn parse_ip(value: &str) -> Option<IpAddr> {
value.parse().ok()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::net::{IpAddr, Ipv4Addr};
use axum::http::{HeaderMap, HeaderValue, header::COOKIE}; use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
use super::rate_limit_key; use super::rate_limit_key;
fn peer() -> Option<IpAddr> {
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
}
#[test] #[test]
fn keys_by_session_cookie_first() { fn unverified_session_cookie_cannot_change_client_key() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
COOKIE, COOKIE,
@@ -88,19 +112,86 @@ mod tests {
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5")); headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
assert_eq!( assert_eq!(
rate_limit_key(&headers, "/api/auth/login"), rate_limit_key(&headers, "/api/auth/login", peer(), true),
"session:sess_123" "ip:10.0.0.5"
); );
} }
#[test] #[test]
fn falls_back_to_forwarded_ip() { fn ignores_forwarded_headers_when_untrusted() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), false),
"ip:203.0.113.7"
);
}
#[test]
fn prefers_real_ip_when_trusted() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
"x-forwarded-for", "x-forwarded-for",
HeaderValue::from_static("10.0.0.5, 10.0.0.6"), HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
);
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
"ip:10.0.0.9"
);
}
#[test]
fn uses_last_forwarded_hop_when_trusted() {
// A client can prepend spoofed entries; the trusted proxy appends the
// real peer, so the last entry is authoritative.
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
); );
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5"); assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
"ip:10.0.0.6"
);
}
#[test]
fn falls_back_to_peer_ip_without_headers() {
let headers = HeaderMap::new();
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
"ip:203.0.113.7"
);
}
#[test]
fn ignores_invalid_forwarded_ip_values() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
headers.insert(
"x-forwarded-for",
HeaderValue::from_static("198.51.100.8, also-not-an-ip"),
);
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
"ip:203.0.113.7"
);
}
#[test]
fn falls_back_to_path_without_peer() {
let headers = HeaderMap::new();
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", None, false),
"anonymous:/api/auth/login"
);
} }
} }
+31 -133
View File
@@ -1,14 +1,13 @@
use axum::{ use axum::{
extract::Request, extract::Request,
http::{HeaderMap, HeaderName, HeaderValue}, http::{HeaderName, HeaderValue},
middleware::Next, middleware::Next,
response::Response, response::Response,
}; };
use tracing::info; use crank_observability::{RequestId, set_remote_trace_parent, with_request_correlation};
use uuid::Uuid; use tracing::{Instrument, info, info_span};
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
const MAX_REQUEST_ID_LEN: usize = 128;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RequestContext { pub struct RequestContext {
@@ -17,149 +16,48 @@ pub struct RequestContext {
pub async fn apply_request_context(mut request: Request, next: Next) -> Response { pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
let context = RequestContext { 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 method = request.method().clone();
let path = request.uri().path().to_owned(); 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()); request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await; with_request_correlation(context.request_id.clone(), async move {
info!( let mut response = next.run(request).instrument(span).await;
request_id = %context.request_id, info!(
method = %method, name: "admin.request.completed",
path, request_id = %context.request_id,
status = response.status().as_u16(), method = %method,
"admin request completed" path,
); status = response.status().as_u16(),
if let Ok(value) = HeaderValue::from_str(&context.request_id) { "admin request completed"
response.headers_mut().insert(REQUEST_ID_HEADER, value); );
} if let Ok(value) = HeaderValue::from_str(&context.request_id) {
response response.headers_mut().insert(REQUEST_ID_HEADER, value);
} }
response
fn resolve_request_id(headers: &HeaderMap) -> String { })
headers .await
.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';')
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn accepts_visible_ascii_request_ids() { fn accepts_visible_ascii_request_ids() {
assert!(is_valid_request_id("req_test_123")); assert!(crank_observability::RequestId::is_valid("req_test_123"));
assert!(is_valid_request_id("trace-123/abc")); assert!(crank_observability::RequestId::is_valid("trace-123/abc"));
} }
#[test] #[test]
fn rejects_empty_or_control_request_ids() { fn rejects_empty_or_control_request_ids() {
assert!(!is_valid_request_id("")); assert!(!crank_observability::RequestId::is_valid(""));
assert!(!is_valid_request_id("bad value")); assert!(!crank_observability::RequestId::is_valid("bad value"));
assert!(!is_valid_request_id("bad\nvalue")); assert!(!crank_observability::RequestId::is_valid("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"));
} }
} }
+25 -1
View File
@@ -10,12 +10,36 @@ pub mod secrets;
pub mod upstreams; pub mod upstreams;
pub mod workspaces; pub mod workspaces;
use axum::Json; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json; use serde_json::json;
use crate::state::AppState;
pub async fn health() -> Json<serde_json::Value> { pub async fn health() -> Json<serde_json::Value> {
Json(json!({ Json(json!({
"service": "admin-api", "service": "admin-api",
"status": "ok" "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()
})),
),
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pub async fn export_workspace(
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let exported = state let exported = state
.service .service
.export_workspace(&path.workspace_id.as_str().into()) .export_workspace_catalog_snapshot(&path.workspace_id.as_str().into())
.await?; .await?;
Ok(Json(json!(exported))) Ok(Json(json!(exported)))
} }
+15 -3
View File
@@ -8,8 +8,8 @@ use serde_json::{Value, json};
use crate::{ use crate::{
error::ApiError, error::ApiError,
service::{ service::{
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
UpdateAgentPayload, ToolSearchPreviewPayload, UpdateAgentPayload,
}, },
state::AppState, state::AppState,
}; };
@@ -50,6 +50,18 @@ pub async fn list_agents(
Ok(Json(json!({ "items": items }))) 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( pub async fn create_agent(
Path(path): Path<WorkspacePath>, Path(path): Path<WorkspacePath>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -124,7 +136,7 @@ pub async fn get_agent_version(
pub async fn save_agent_bindings( pub async fn save_agent_bindings(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<Vec<AgentBindingPayload>>, Json(payload): Json<AgentCatalogPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let record = state let record = state
.service .service
+1 -1
View File
@@ -103,7 +103,7 @@ pub async fn change_password(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
state state
.service .service
.change_password(&session.user.id, payload) .change_password(&session.user.id, &session.session_id, payload)
.await?; .await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+287 -56
View File
@@ -5,21 +5,25 @@ use std::sync::Arc;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{ use crank_core::{
AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities, AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities,
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId, NoopAuditSink, ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId,
OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine, ProductEdition, Protocol, InvocationSource, NoopAuditSink, OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine,
ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind, ToolQualitySchemaNode, ProductEdition, Protocol, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind,
UsagePeriod, WorkspaceId, ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
}; };
use crank_mapping::{MappingRule, MappingSet}; use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{ use crank_registry::{
AgentSummary, CreateInvocationLogRequest, OperationAgentRef, OperationSummary, AgentSummary, CreateInvocationLogRequest, InvocationHistoryWriteOutcome, OperationAgentRef,
OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket, 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_schema::{Schema, SchemaKind};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::Instrument;
use uuid::Uuid; use uuid::Uuid;
mod agents; mod agents;
@@ -38,8 +42,8 @@ mod workspaces;
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage}; use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
use operation_validation::{ use operation_validation::{
validate_approval_policy, validate_idempotency_policy, validate_protocol_target, validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
validate_response_cache_policy, validate_protocol_target, validate_response_cache_policy,
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -53,6 +57,7 @@ pub struct AdminService {
policy_engine: Arc<dyn PolicyEngine>, policy_engine: Arc<dyn PolicyEngine>,
audit_sink: Arc<dyn AuditSink>, audit_sink: Arc<dyn AuditSink>,
capability_profile: Arc<dyn CapabilityProfile>, capability_profile: Arc<dyn CapabilityProfile>,
outbound_http_policy: OutboundHttpPolicy,
} }
pub struct AdminServiceBuilder { pub struct AdminServiceBuilder {
@@ -65,11 +70,17 @@ pub struct AdminServiceBuilder {
policy_engine: Option<Arc<dyn PolicyEngine>>, policy_engine: Option<Arc<dyn PolicyEngine>>,
audit_sink: Option<Arc<dyn AuditSink>>, audit_sink: Option<Arc<dyn AuditSink>>,
capability_profile: Option<Arc<dyn CapabilityProfile>>, capability_profile: Option<Arc<dyn CapabilityProfile>>,
outbound_http_policy: OutboundHttpPolicy,
} }
pub use crate::dto::*; pub use crate::dto::*;
impl AdminService { impl AdminService {
pub async fn readiness(&self) -> Result<(), ApiError> {
self.registry.ping().await?;
Ok(())
}
#[cfg(test)] #[cfg(test)]
pub fn new( pub fn new(
registry: PostgresRegistry, registry: PostgresRegistry,
@@ -139,6 +150,7 @@ impl AdminServiceBuilder {
policy_engine: None, policy_engine: None,
audit_sink: None, audit_sink: None,
capability_profile: None, capability_profile: None,
outbound_http_policy: OutboundHttpPolicy::default(),
} }
} }
@@ -147,6 +159,11 @@ impl AdminServiceBuilder {
self self
} }
pub fn with_outbound_http_policy(mut self, policy: OutboundHttpPolicy) -> Self {
self.outbound_http_policy = policy;
self
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self { pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self {
self.policy_engine = Some(policy_engine); self.policy_engine = Some(policy_engine);
@@ -183,21 +200,39 @@ impl AdminServiceBuilder {
capability_profile: self capability_profile: self
.capability_profile .capability_profile
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)), .unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
outbound_http_policy: self.outbound_http_policy,
} }
} }
} }
impl AdminService { impl AdminService {
pub async fn export_workspace( pub async fn export_workspace_catalog_snapshot(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
) -> Result<WorkspaceExportResponse, ApiError> { ) -> Result<WorkspaceCatalogSnapshotResponse, ApiError> {
let workspace = self.get_workspace(workspace_id).await?; let workspace = self.get_workspace(workspace_id).await?;
let operations = self.list_operations(workspace_id).await?; let operations = self.list_operations(workspace_id).await?;
let agents = self.list_agents(workspace_id).await?; let agents = self.list_agents(workspace_id).await?;
let platform_api_keys = self.registry.list_platform_api_keys(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, workspace,
operations, operations,
agents, agents,
@@ -228,9 +263,13 @@ impl AdminService {
return Ok(None); return Ok(None);
}; };
let auth_profile = self let span = Stage::AuthResolve.span();
.registry let result = async {
.get_auth_profile(workspace_id, auth_profile_id) let auth_profile = observe_db_query(
DbOperation::AuthProfileRead,
self.registry
.get_auth_profile(workspace_id, auth_profile_id),
)
.await .await
.map_err(|error| RuntimeError::SecretCrypto { .map_err(|error| RuntimeError::SecretCrypto {
operation: "load auth profile", operation: "load auth profile",
@@ -240,9 +279,20 @@ impl AdminService {
auth_profile_id: auth_profile_id.as_str().to_owned(), auth_profile_id: auth_profile_id.as_str().to_owned(),
})?; })?;
self.resolve_auth_profile(workspace_id, &auth_profile) self.resolve_auth_profile(workspace_id, &auth_profile)
.await .await
.map(Some) .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( async fn resolve_auth_profile(
@@ -254,40 +304,46 @@ impl AdminService {
let used_at = OffsetDateTime::now_utc(); let used_at = OffsetDateTime::now_utc();
for secret_id in auth_profile.config.secret_ids() { for secret_id in auth_profile.config.secret_ids() {
let secret = self let secret = observe_db_query(
.registry DbOperation::SecretRead,
.get_secret(workspace_id, secret_id) self.registry.get_secret(workspace_id, secret_id),
.await )
.map_err(|error| RuntimeError::SecretCrypto { .await
operation: "load secret", .map_err(|error| RuntimeError::SecretCrypto {
details: error.to_string(), operation: "load secret",
})? details: error.to_string(),
.ok_or_else(|| RuntimeError::MissingSecret { })?
secret_id: secret_id.as_str().to_owned(), .ok_or_else(|| RuntimeError::MissingSecret {
})?; secret_id: secret_id.as_str().to_owned(),
let version = self })?;
.registry let version = observe_db_query(
.get_current_secret_version(workspace_id, secret_id) DbOperation::SecretRead,
.await self.registry
.map_err(|error| RuntimeError::SecretCrypto { .get_current_secret_version(workspace_id, secret_id),
operation: "load current secret version", )
details: error.to_string(), .await
})? .map_err(|error| RuntimeError::SecretCrypto {
.ok_or_else(|| RuntimeError::MissingSecretVersion { operation: "load current secret version",
secret_id: secret_id.as_str().to_owned(), details: error.to_string(),
version: secret.secret.current_version, })?
})?; .ok_or_else(|| RuntimeError::MissingSecretVersion {
secret_id: secret_id.as_str().to_owned(),
version: secret.secret.current_version,
})?;
let plaintext = self.secret_crypto.decrypt( let plaintext = self.secret_crypto.decrypt(
&version.secret_version.key_version, &version.secret_version.key_version,
&version.secret_version.ciphertext, &version.secret_version.ciphertext,
)?; )?;
self.registry observe_db_query(
.touch_secret(workspace_id, secret_id, &used_at) DbOperation::SecretTouch,
.await self.registry
.map_err(|error| RuntimeError::SecretCrypto { .touch_secret(workspace_id, secret_id, &used_at),
operation: "touch secret", )
details: error.to_string(), .await
})?; .map_err(|error| RuntimeError::SecretCrypto {
operation: "touch secret",
details: error.to_string(),
})?;
secrets.insert(secret_id.clone(), plaintext); secrets.insert(secret_id.clone(), plaintext);
} }
@@ -297,6 +353,8 @@ impl AdminService {
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> { fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
self.validate_operation_capabilities(payload.protocol, payload.security_level)?; self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
validate_protocol_target(payload.protocol, &payload.target)?; validate_protocol_target(payload.protocol, &payload.target)?;
self.validate_outbound_target(&payload.target)?;
validate_execution_timeout(&payload.execution_config)?;
validate_response_cache_policy(&payload.target, &payload.execution_config)?; validate_response_cache_policy(&payload.target, &payload.execution_config)?;
validate_idempotency_policy(&payload.target, &payload.execution_config)?; validate_idempotency_policy(&payload.target, &payload.execution_config)?;
validate_approval_policy(&payload.execution_config)?; validate_approval_policy(&payload.execution_config)?;
@@ -308,6 +366,8 @@ impl AdminService {
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> { fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
self.validate_operation_capabilities(operation.protocol, operation.security_level)?; self.validate_operation_capabilities(operation.protocol, operation.security_level)?;
validate_protocol_target(operation.protocol, &operation.target)?; validate_protocol_target(operation.protocol, &operation.target)?;
self.validate_outbound_target(&operation.target)?;
validate_execution_timeout(&operation.execution_config)?;
validate_response_cache_policy(&operation.target, &operation.execution_config)?; validate_response_cache_policy(&operation.target, &operation.execution_config)?;
validate_idempotency_policy(&operation.target, &operation.execution_config)?; validate_idempotency_policy(&operation.target, &operation.execution_config)?;
validate_approval_policy(&operation.execution_config)?; validate_approval_policy(&operation.execution_config)?;
@@ -316,6 +376,15 @@ impl AdminService {
Ok(()) Ok(())
} }
fn validate_outbound_target(&self, target: &crank_core::Target) -> Result<(), ApiError> {
match target {
crank_core::Target::Rest(rest) => self
.outbound_http_policy
.validate_base_url(&rest.base_url)
.map_err(|error| ApiError::validation(error.to_string())),
}
}
fn validate_operation_capabilities( fn validate_operation_capabilities(
&self, &self,
protocol: Protocol, protocol: Protocol,
@@ -388,7 +457,7 @@ impl AdminService {
async fn record_invocation( async fn record_invocation(
&self, &self,
request: InvocationRecordRequest<'_>, request: InvocationRecordRequest<'_>,
) -> Result<(), ApiError> { ) -> InvocationHistoryWriteOutcome {
let log = InvocationLog { let log = InvocationLog {
id: InvocationLogId::new(new_prefixed_id("log")), id: InvocationLogId::new(new_prefixed_id("log")),
workspace_id: request.workspace_id.clone(), workspace_id: request.workspace_id.clone(),
@@ -408,11 +477,75 @@ impl AdminService {
created_at: OffsetDateTime::now_utc(), created_at: OffsetDateTime::now_utc(),
}; };
self.registry let history_span = crank_trace::Stage::HistoryWrite.span();
.create_invocation_log(CreateInvocationLogRequest { log: &log }) let (outcome, db_span) = async {
.await?; 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",
} }
} }
@@ -478,9 +611,9 @@ fn new_prefixed_id(prefix: &str) -> String {
format!("{prefix}_{}", Uuid::now_v7().simple()) 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()); 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 { fn hash_access_secret(secret: &str) -> String {
@@ -519,6 +652,10 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
RuntimeError::ConfirmationRequired { .. } => "confirmation_required", RuntimeError::ConfirmationRequired { .. } => "confirmation_required",
RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token", RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token",
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable", 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::RestAdapter(_) => "rest_error",
RuntimeError::ProtocolAdapter(_) => "adapter_error", RuntimeError::ProtocolAdapter(_) => "adapter_error",
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
@@ -674,6 +811,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
published_at: summary.published_at.map(format_timestamp), published_at: summary.published_at.map(format_timestamp),
operation_count: 0, operation_count: 0,
operation_ids: Vec::new(), operation_ids: Vec::new(),
tool_selection_policy: Default::default(),
key_count: 0, key_count: 0,
calls_today: 0, calls_today: 0,
mcp_endpoint: String::new(), mcp_endpoint: String::new(),
@@ -736,7 +874,25 @@ fn tool_quality_mapping_rule(rule: &MappingRule) -> ToolQualityMappingRule {
#[cfg(test)] #[cfg(test)]
#[allow(clippy::items_after_test_module)] #[allow(clippy::items_after_test_module)]
mod tests { 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] #[test]
fn validates_profile_identity_fields() { fn validates_profile_identity_fields() {
@@ -757,6 +913,81 @@ mod tests {
assert!(validate_profile_display_name(&"x".repeat(81)).is_err()); assert!(validate_profile_display_name(&"x".repeat(81)).is_err());
assert!(validate_profile_email("owner <html>@crank.local").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( fn enrich_operation_summary(
+182 -16
View File
@@ -1,12 +1,12 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crank_core::{ use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, UsagePeriod, Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool,
WorkspaceId, ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
}; };
use crank_registry::{ use crank_registry::{
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
SaveAgentBindingsRequest, UsageBucket, UsageQuery, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
}; };
use serde_json::json; use serde_json::json;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -15,13 +15,93 @@ use tracing::{info, instrument};
use crate::{ use crate::{
error::ApiError, error::ApiError,
service::{ service::{
AdminService, AgentBindingPayload, AgentMutationResult, AgentPayload, AgentSummaryView, AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
CreatedAgentResponse, PublishAgentResponse, UpdateAgentPayload, agent_mcp_endpoint, CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
format_timestamp, map_agent_summary_view, new_prefixed_id, today_start_utc, agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
today_start_utc,
}, },
}; };
impl AdminService { 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))] #[instrument(skip(self))]
pub async fn list_agents( pub async fn list_agents(
&self, &self,
@@ -69,6 +149,7 @@ impl AdminService {
items.push(AgentSummaryView { items.push(AgentSummaryView {
operation_count: operation_ids.len(), operation_count: operation_ids.len(),
operation_ids, operation_ids,
tool_selection_policy: version.snapshot.tool_selection_policy,
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0), 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), calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
mcp_endpoint: agent_mcp_endpoint( mcp_endpoint: agent_mcp_endpoint(
@@ -130,6 +211,7 @@ impl AdminService {
Ok(AgentSummaryView { Ok(AgentSummaryView {
operation_count: operation_ids.len(), operation_count: operation_ids.len(),
operation_ids, operation_ids,
tool_selection_policy: version.snapshot.tool_selection_policy,
key_count, key_count,
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0), calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
mcp_endpoint: agent_mcp_endpoint( mcp_endpoint: agent_mcp_endpoint(
@@ -214,7 +296,12 @@ impl AdminService {
bindings: &[], bindings: &[],
}) })
.await?; .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 { Ok(CreatedAgentResponse {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -304,9 +391,12 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
payload: Vec<AgentBindingPayload>, payload: AgentCatalogPayload,
) -> Result<AgentVersionRecord, ApiError> { ) -> 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 let bindings = payload
.into_iter() .into_iter()
.map(|binding| AgentOperationBinding { .map(|binding| AgentOperationBinding {
@@ -318,23 +408,63 @@ impl AdminService {
enabled: binding.enabled, enabled: binding.enabled,
}) })
.collect::<Vec<_>>(); .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 self.registry
.save_agent_bindings(SaveAgentBindingsRequest { .save_agent_catalog_config(SaveAgentCatalogConfigRequest {
workspace_id, workspace_id,
agent_id, agent_id,
agent_version: agent.current_draft_version, agent_version: current_version.version,
bindings: &bindings, bindings: &bindings,
tool_selection_policy: &tool_selection_policy,
}) })
.await?; .await?;
info!( info!(
name: "admin.agent.bindings_saved",
agent_id = %agent_id.as_str(), agent_id = %agent_id.as_str(),
version = agent.current_draft_version, version = current_version.version,
binding_count = bindings.len(), binding_count = bindings.len(),
"agent bindings saved" "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: &current.bindings,
updated_at: &now,
})
.await?;
self.get_agent_version(workspace_id, agent_id, draft.version)
.await .await
} }
@@ -351,6 +481,10 @@ impl AdminService {
let published_bindings = self let published_bindings = self
.published_agent_bindings(workspace_id, &agent_version.bindings) .published_agent_bindings(workspace_id, &agent_version.bindings)
.await?; .await?;
validate_tool_selection_policy(
&agent_version.snapshot.tool_selection_policy,
&published_bindings,
)?;
if published_bindings.is_empty() { if published_bindings.is_empty() {
return Err(ApiError::conflict_with_context( return Err(ApiError::conflict_with_context(
@@ -403,7 +537,12 @@ impl AdminService {
published_by: None, published_by: None,
}) })
.await?; .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 { Ok(PublishAgentResponse {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -461,7 +600,11 @@ impl AdminService {
self.registry self.registry
.unpublish_agent(workspace_id, agent_id, &updated_at) .unpublish_agent(workspace_id, agent_id, &updated_at)
.await?; .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 { Ok(AgentMutationResult {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -481,7 +624,11 @@ impl AdminService {
self.registry self.registry
.archive_agent(workspace_id, agent_id, &updated_at) .archive_agent(workspace_id, agent_id, &updated_at)
.await?; .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 { Ok(AgentMutationResult {
agent_id: agent_id.as_str().to_owned(), 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 -4
View File
@@ -65,10 +65,7 @@ impl AdminService {
), ),
None => None, None => None,
}; };
let secret = generate_access_secret(match payload.key_kind { let secret = generate_access_secret(payload.key_kind.secret_marker());
PlatformApiKeyKind::McpClient => "crk",
PlatformApiKeyKind::Approval => "crk_appr",
});
let api_key = PlatformApiKeyRecord { let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey { api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")), id: PlatformApiKeyId::new(new_prefixed_id("pk")),
+7 -2
View File
@@ -22,7 +22,7 @@ impl AdminService {
)?; )?;
let user_id = self let user_id = self
.registry .registry
.upsert_bootstrap_user( .ensure_bootstrap_user(
&self.auth_settings.bootstrap_admin.email, &self.auth_settings.bootstrap_admin.email,
&self.auth_settings.bootstrap_admin.display_name, &self.auth_settings.bootstrap_admin.display_name,
&password_hash, &password_hash,
@@ -227,6 +227,7 @@ impl AdminService {
pub async fn change_password( pub async fn change_password(
&self, &self,
user_id: &crank_core::UserId, user_id: &crank_core::UserId,
current_session_id: &UserSessionId,
payload: ChangePasswordPayload, payload: ChangePasswordPayload,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
if payload.new_password.len() < 12 { if payload.new_password.len() < 12 {
@@ -257,7 +258,11 @@ impl AdminService {
let password_hash = let password_hash =
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?; hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
self.registry self.registry
.update_user_password(user_id, &password_hash) .update_user_password_and_revoke_other_sessions(
user_id,
current_session_id,
&password_hash,
)
.await?; .await?;
Ok(()) Ok(())
+4 -6
View File
@@ -131,6 +131,7 @@ impl AdminService {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => { Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => {
tracing::warn!( tracing::warn!(
name: "admin.demo_operation.cleanup_skipped",
operation_id = %operation_id.as_str(), operation_id = %operation_id.as_str(),
"legacy demo operation is still bound to a published agent; leaving it in place" "legacy demo operation is still bound to a published agent; leaving it in place"
); );
@@ -272,7 +273,7 @@ impl AdminService {
publish: bool, publish: bool,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let summary = self.get_agent(workspace_id, agent_id).await?; 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?; .await?;
if publish && summary.latest_published_version.is_none() { if publish && summary.latest_published_version.is_none() {
self.publish_agent(workspace_id, agent_id, summary.current_draft_version) self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
@@ -335,7 +336,7 @@ impl AdminService {
}), }),
response_preview: demo_rest_response_sample(), response_preview: demo_rest_response_sample(),
}) })
.await?; .await;
Ok(()) Ok(())
} }
} }
@@ -348,10 +349,7 @@ fn demo_currency_agent_payload() -> AgentPayload {
instructions: json!({ instructions: json!({
"system": "Используй инструменты Frankfurter только для запросов о курсах валют." "system": "Используй инструменты Frankfurter только для запросов о курсах валют."
}), }),
tool_selection_policy: json!({ tool_selection_policy: Default::default(),
"max_tools": 4,
"prefer_tag": ["currency", "exchange-rate"]
}),
} }
} }
@@ -110,6 +110,7 @@ impl AdminService {
warnings, warnings,
}; };
info!( info!(
name: "admin.operation.imported",
operation_id = %response.operation_id, operation_id = %response.operation_id,
version = response.version, version = response.version,
"operation imported by upsert" "operation imported by upsert"
@@ -125,6 +126,7 @@ impl AdminService {
warnings, warnings,
}; };
info!( info!(
name: "admin.operation.imported",
operation_id = %response.operation_id, operation_id = %response.operation_id,
version = response.version, version = response.version,
"operation imported by upsert" "operation imported by upsert"
+86 -76
View File
@@ -8,9 +8,11 @@ use crank_import::rest::{
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate, ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate,
}; };
use crank_registry::{ use crank_registry::{
CreateImportJobRequest, FinishImportJobRequest, ImportJobId, ImportJobKind, ImportJobStatus, ApplyImportJobRequest, CreateImportJobRequest, ImportConflictMode, ImportJobId, ImportJobKind,
ImportJobStatus, ImportOperationDraft,
}; };
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument}; use tracing::{info, instrument};
@@ -50,7 +52,7 @@ impl AdminService {
kind: ImportJobKind::OpenApi, kind: ImportJobKind::OpenApi,
source_format: &preview.source.format, source_format: &preview.source.format,
source_version: preview.source.version.as_deref(), source_version: preview.source.version.as_deref(),
status: ImportJobStatus::Completed, status: ImportJobStatus::Pending,
preview_payload: &preview_payload, preview_payload: &preview_payload,
created_at: &now, created_at: &now,
expires_at: &expires_at, expires_at: &expires_at,
@@ -99,9 +101,13 @@ impl AdminService {
return Err(ApiError::validation("import job kind is not openapi")); return Err(ApiError::validation("import job kind is not openapi"));
} }
let preview: crank_import::rest::ImportPreview = let stored_preview = job
serde_json::from_value(job.preview_payload.clone()) .preview_payload
.map_err(|error| ApiError::internal(error.to_string()))?; .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 let selected = payload
.selected_operation_keys .selected_operation_keys
.iter() .iter()
@@ -120,10 +126,8 @@ impl AdminService {
} }
} }
let mut created = Vec::new();
let mut skipped = Vec::new(); let mut skipped = Vec::new();
let mut findings = Vec::new(); let mut operations = Vec::new();
let mut created_ids = Vec::new();
for operation_key in selected { for operation_key in selected {
let Some(candidate) = candidates.get(&operation_key) else { let Some(candidate) = candidates.get(&operation_key) else {
@@ -137,44 +141,7 @@ impl AdminService {
let mut draft = let mut draft =
operation_draft_from_candidate(candidate, payload.server_url.as_deref()); operation_draft_from_candidate(candidate, payload.server_url.as_deref());
attach_import_findings(&mut draft, candidate); attach_import_findings(&mut draft, candidate);
if let Some(existing_name) = self let operation = self.new_operation_snapshot(OperationPayload {
.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 {
name: draft.name.clone(), name: draft.name.clone(),
display_name: draft.display_name.clone(), display_name: draft.display_name.clone(),
category: draft.category, category: draft.category,
@@ -197,27 +164,74 @@ impl AdminService {
}, },
tool_description: draft.tool_description, tool_description: draft.tool_description,
wizard_state: draft.wizard_state, wizard_state: draft.wizard_state,
}; })?;
let result = self.create_operation(workspace_id, payload).await?; operations.push(ImportOperationDraft {
created_ids.push(result.operation_id.clone()); operation_key: candidate.key.clone(),
created.push(OpenApiImportCreatedOperation { operation,
operation_id: result.operation_id,
name: draft.name,
version: result.version,
}); });
} }
let finished_at = OffsetDateTime::now_utc(); let finished_at = OffsetDateTime::now_utc();
self.registry let application_key = openapi_application_key(&payload)?;
.finish_import_job(FinishImportJobRequest { let conflict_mode = if payload.conflict_mode == "skip" {
ImportConflictMode::Skip
} else {
ImportConflictMode::Rename
};
let applied = self
.registry
.apply_import_job(ApplyImportJobRequest {
id: job_id, id: job_id,
status: ImportJobStatus::Completed, workspace_id,
created_operation_ids: &json!(created_ids), application_key: &application_key,
error_text: None, conflict_mode,
operations: &operations,
finished_at: &finished_at, finished_at: &finished_at,
}) })
.await?; .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!( info!(
name: "admin.openapi_import.completed",
created = created.len(), created = created.len(),
skipped = skipped.len(), skipped = skipped.len(),
"openapi import created drafts" "openapi import created drafts"
@@ -229,25 +243,21 @@ impl AdminService {
findings, findings,
}) })
} }
}
async fn next_available_operation_name( fn openapi_application_key(payload: &OpenApiImportCreatePayload) -> Result<String, ApiError> {
&self, let selected_operation_keys = payload
workspace_id: &WorkspaceId, .selected_operation_keys
base_name: &str, .iter()
) -> Result<String, ApiError> { .cloned()
for index in 2.. { .collect::<BTreeSet<_>>();
let candidate = format!("{base_name}_{index}"); let canonical = serde_json::to_vec(&json!({
if self "selected_operation_keys": selected_operation_keys,
.find_operation_by_name(workspace_id, &candidate) "server_url": payload.server_url.as_deref(),
.await? "conflict_mode": payload.conflict_mode.as_str(),
.is_none() }))
{ .map_err(|error| ApiError::internal(error.to_string()))?;
return Ok(candidate); Ok(format!("{:x}", Sha256::digest(canonical)))
}
}
unreachable!()
}
} }
fn attach_import_findings( fn attach_import_findings(
@@ -19,6 +19,16 @@ use crate::{
}; };
impl AdminService { 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))] #[instrument(skip(self))]
pub async fn list_logs( pub async fn list_logs(
&self, &self,
@@ -3,6 +3,8 @@ use serde_json::json;
use crate::error::ApiError; use crate::error::ApiError;
const MAX_OPERATION_TIMEOUT_MS: u64 = 300_000;
pub(super) fn validate_protocol_target( pub(super) fn validate_protocol_target(
protocol: Protocol, protocol: Protocol,
target: &Target, target: &Target,
@@ -16,6 +18,18 @@ pub(super) fn validate_protocol_target(
Err(ApiError::validation("protocol and target kind must match")) Err(ApiError::validation("protocol and target kind must match"))
} }
pub(super) fn validate_execution_timeout(
execution_config: &crank_core::ExecutionConfig,
) -> Result<(), ApiError> {
if !(1..=MAX_OPERATION_TIMEOUT_MS).contains(&execution_config.timeout_ms) {
return Err(ApiError::validation_with_context(
format!("operation timeout must be between 1 and {MAX_OPERATION_TIMEOUT_MS} ms"),
json!({ "field": "execution_config.timeout_ms" }),
));
}
Ok(())
}
pub(super) fn validate_response_cache_policy( pub(super) fn validate_response_cache_policy(
target: &Target, target: &Target,
execution_config: &crank_core::ExecutionConfig, execution_config: &crank_core::ExecutionConfig,
@@ -150,7 +164,8 @@ mod tests {
}; };
use super::{ use super::{
validate_approval_policy, validate_idempotency_policy, validate_response_cache_policy, validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
validate_response_cache_policy,
}; };
fn cacheable_execution_config() -> ExecutionConfig { fn cacheable_execution_config() -> ExecutionConfig {
@@ -198,6 +213,19 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test]
fn rejects_zero_and_excessive_execution_timeouts() {
let mut config = cacheable_execution_config();
config.timeout_ms = 0;
assert!(validate_execution_timeout(&config).is_err());
config.timeout_ms = 300_001;
assert!(validate_execution_timeout(&config).is_err());
config.timeout_ms = 300_000;
assert!(validate_execution_timeout(&config).is_ok());
}
#[test] #[test]
fn rejects_response_cache_for_non_get_rest_operation() { fn rejects_response_cache_for_non_get_rest_operation() {
let target = Target::Rest(RestTarget { let target = Target::Rest(RestTarget {
+81 -52
View File
@@ -141,7 +141,6 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
payload: OperationPayload, payload: OperationPayload,
) -> Result<CreatedOperationResponse, ApiError> { ) -> Result<CreatedOperationResponse, ApiError> {
self.validate_operation_payload(&payload)?;
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
if self 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 now = OffsetDateTime::now_utc();
let operation_id = OperationId::new(new_prefixed_id("op")); Ok(RegistryOperation {
let snapshot = RegistryOperation { id: OperationId::new(new_prefixed_id("op")),
id: operation_id.clone(),
name: payload.name, name: payload.name,
display_name: payload.display_name, display_name: payload.display_name,
category: payload.category, category: payload.category,
@@ -183,19 +208,6 @@ impl AdminService {
created_at: now, created_at: now,
updated_at: now, updated_at: now,
published_at: None, 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, created_by: None,
}) })
.await?; .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 { Ok(CreatedOperationResponse {
operation_id: operation_id.as_str().to_owned(), operation_id: operation_id.as_str().to_owned(),
@@ -364,7 +381,12 @@ impl AdminService {
published_by: None, published_by: None,
}) })
.await?; .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 { Ok(PublishResponse {
operation_id: operation_id.as_str().to_owned(), operation_id: operation_id.as_str().to_owned(),
@@ -431,37 +453,44 @@ impl AdminService {
.await?; .await?;
let runtime = RuntimeOperation::from(record.snapshot.clone()); let runtime = RuntimeOperation::from(record.snapshot.clone());
let mode = ExecutionMode::Unary; let mode = ExecutionMode::Unary;
let request_preview = let preview_span = crank_trace::Stage::RuntimeArgumentsMap.span();
match build_request_preview(&record.snapshot.input_mapping, &payload.input) { let preview_result = preview_span
Ok(preview) => preview, .in_scope(|| build_request_preview(&record.snapshot.input_mapping, &payload.input));
Err(error) => { let request_preview = match preview_result {
self.record_invocation(InvocationRecordRequest { Ok(preview) => preview,
workspace_id, Err(error) => {
agent_id: None, crank_trace::StageOutcome::Error.record(&preview_span);
operation: &record.snapshot, crank_trace::ErrorCategory::Mapping.record(&preview_span);
request_id: Some(request_id), drop(preview_span);
source: InvocationSource::AdminTestRun, self.record_invocation(InvocationRecordRequest {
level: InvocationLevel::Error, workspace_id,
status: InvocationStatus::Error, agent_id: None,
message: "mapping preview failed".to_owned(), operation: &record.snapshot,
status_code: None, request_id: Some(request_id),
error_kind: Some("mapping".to_owned()), source: InvocationSource::AdminTestRun,
duration_ms: 0, level: InvocationLevel::Error,
request_preview: Value::Null, status: InvocationStatus::Error,
response_preview: Value::Null, message: "mapping preview failed".to_owned(),
}) status_code: None,
.await?; error_kind: Some("mapping".to_owned()),
return Ok(TestRunResult { duration_ms: 0,
ok: false, request_preview: Value::Null,
mode, response_preview: Value::Null,
request_preview: Value::Null, })
response_preview: Value::Null, .await;
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping( return Ok(TestRunResult {
error, 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 let resolved_auth = self
.resolve_operation_auth(workspace_id, &runtime.execution_config) .resolve_operation_auth(workspace_id, &runtime.execution_config)
@@ -497,7 +526,7 @@ impl AdminService {
request_preview: request_preview.clone(), request_preview: request_preview.clone(),
response_preview: response_preview.clone(), response_preview: response_preview.clone(),
}) })
.await?; .await;
Ok(TestRunResult { Ok(TestRunResult {
ok: true, ok: true,
mode, mode,
@@ -524,7 +553,7 @@ impl AdminService {
request_preview: request_preview.clone(), request_preview: request_preview.clone(),
response_preview: Value::Null, response_preview: Value::Null,
}) })
.await?; .await;
Ok(TestRunResult { Ok(TestRunResult {
ok: false, ok: false,
mode, mode,
+6 -1
View File
@@ -50,6 +50,7 @@ impl AdminService {
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata }) .save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
.await?; .await?;
info!( info!(
name: "admin.sample.saved",
operation_id = %operation_id.as_str(), operation_id = %operation_id.as_str(),
sample_id = %metadata.id.as_str(), sample_id = %metadata.id.as_str(),
version, version,
@@ -119,7 +120,11 @@ impl AdminService {
input_mapping, input_mapping,
output_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) Ok(result)
} }
+20 -4
View File
@@ -92,7 +92,11 @@ impl AdminService {
created_by, created_by,
}) })
.await?; .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) Ok(secret)
} }
@@ -126,7 +130,11 @@ impl AdminService {
created_by, created_by,
}) })
.await?; .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 self.get_secret(workspace_id, secret_id).await
} }
@@ -152,7 +160,11 @@ impl AdminService {
.into()); .into());
} }
self.registry.delete_secret(workspace_id, secret_id).await?; 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(()) Ok(())
} }
@@ -201,7 +213,11 @@ impl AdminService {
profile: &profile, profile: &profile,
}) })
.await?; .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) Ok(profile)
} }
+5 -1
View File
@@ -75,7 +75,11 @@ impl AdminService {
upstream: &upstream, upstream: &upstream,
}) })
.await?; .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) Ok(upstream)
} }
+6
View File
@@ -5,4 +5,10 @@ use crank_runtime::RequestRateLimiter;
pub struct AppState { pub struct AppState {
pub service: AdminService, pub service: AdminService,
pub api_rate_limiter: RequestRateLimiter, pub api_rate_limiter: RequestRateLimiter,
/// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification.
///
/// Only enable when the service sits behind a trusted reverse proxy that
/// overwrites these headers (e.g. the bundled nginx). When disabled the
/// real TCP peer address is used, which a client cannot spoof.
pub trust_forwarded_headers: bool,
} }
+208
View File
@@ -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( api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(), crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
), ),
trust_forwarded_headers: false,
}); });
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
axum::serve(listener, app) axum::serve(
.with_graceful_shutdown(async move { listener,
let _ = shutdown_rx.await; app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
}) )
.await .with_graceful_shutdown(async move {
.unwrap(); let _ = shutdown_rx.await;
})
.await
.unwrap();
}); });
let client = reqwest::Client::new(); let client = reqwest::Client::new();
+5 -1
View File
@@ -104,6 +104,7 @@ pub(super) fn build_test_app(
api_rate_limiter: crank_runtime::RequestRateLimiter::new( api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(), crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
), ),
trust_forwarded_headers: false,
}) })
} }
@@ -113,13 +114,16 @@ pub(super) fn test_service(
auth_settings: AuthSettings, auth_settings: AuthSettings,
secret_crypto: SecretCrypto, secret_crypto: SecretCrypto,
) -> AdminService { ) -> AdminService {
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
AdminServiceBuilder::new( AdminServiceBuilder::new(
registry, registry,
storage_root, storage_root,
auth_settings, auth_settings,
secret_crypto, secret_crypto,
crank_runtime::RuntimeExecutor::new(), runtime,
) )
.with_outbound_http_policy(outbound_policy)
.build() .build()
} }
@@ -23,6 +23,7 @@ use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json}; use serde_json::{Value, json};
use serial_test::serial; use serial_test::serial;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use uuid::Version;
use admin_api::{ use admin_api::{
app::build_app, app::build_app,
@@ -385,6 +386,30 @@ async fn exports_single_workspace_but_rejects_access_lifecycle() {
exported["workspace"]["workspace"]["id"], exported["workspace"]["workspace"]["id"],
DEFAULT_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("memberships").is_none());
assert!(exported.get("invitations").is_none()); assert!(exported.get("invitations").is_none());
@@ -464,7 +489,7 @@ async fn seeds_demo_assets_for_live_ui() {
display_name: "Legacy Smoke Agent".to_owned(), display_name: "Legacy Smoke Agent".to_owned(),
description: "Keeps a legacy smoke operation published".to_owned(), description: "Keeps a legacy smoke operation published".to_owned(),
instructions: json!({}), instructions: json!({}),
tool_selection_policy: json!({}), tool_selection_policy: Default::default(),
}, },
) )
.await .await
@@ -481,7 +506,8 @@ async fn seeds_demo_assets_for_live_ui() {
tool_title: "Legacy health smoke".to_owned(), tool_title: "Legacy health smoke".to_owned(),
tool_description_override: None, tool_description_override: None,
enabled: true, enabled: true,
}], }]
.into(),
) )
.await .await
.unwrap(); .unwrap();
@@ -573,6 +599,25 @@ async fn updates_profile_and_changes_password() {
.unwrap() .unwrap()
.to_owned(); .to_owned();
let client = authorized_client(&base_url).await; 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( let profile = assert_success_json(
client client
@@ -614,6 +659,21 @@ async fn updates_profile_and_changes_password() {
.status(); .status();
assert_eq!(password_status, reqwest::StatusCode::NO_CONTENT); 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() let relogin_client = reqwest::Client::builder()
.cookie_store(true) .cookie_store(true)
.build() .build()
@@ -854,7 +914,10 @@ async fn generates_request_id_for_test_run_invocations() {
.unwrap() .unwrap()
.to_owned(); .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(); response.error_for_status().unwrap();
let logs = client let logs = client
@@ -1,5 +1,6 @@
use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload}; use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload};
use crank_core::WorkspaceId; use crank_core::WorkspaceId;
use crank_registry::ImportJobStatus;
use serial_test::serial; use serial_test::serial;
use super::common::{ use super::common::{
@@ -42,7 +43,7 @@ paths:
async fn previews_openapi_and_creates_draft_operations() { async fn previews_openapi_and_creates_draft_operations() {
let registry = test_registry().await; let registry = test_registry().await;
let service = test_service( let service = test_service(
registry, registry.clone(),
test_storage_root("openapi_import"), test_storage_root("openapi_import"),
test_auth_settings(), test_auth_settings(),
test_secret_crypto(), test_secret_crypto(),
@@ -64,6 +65,12 @@ async fn previews_openapi_and_creates_draft_operations() {
preview.preview.groups[0].operations[0].suggested_name, preview.preview.groups[0].operations[0].suggested_name,
"latest_rates" "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 let created = service
.create_openapi_import( .create_openapi_import(
@@ -112,10 +119,19 @@ async fn previews_openapi_and_creates_draft_operations() {
.any(|finding| finding.code == "openapi_import.weak_tool_description") .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 let skipped = service
.create_openapi_import( .create_openapi_import(
&workspace_id, &workspace_id,
&preview.job_id.as_str().into(), &skip_preview.job_id.as_str().into(),
OpenApiImportCreatePayload { OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()], selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".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.skipped[0].name, "latest_rates");
assert_eq!(skipped.findings[0].code, "operation_name_conflict"); 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 let renamed = service
.create_openapi_import( .create_openapi_import(
&workspace_id, &workspace_id,
&preview.job_id.as_str().into(), &rename_preview.job_id.as_str().into(),
OpenApiImportCreatePayload { OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()], selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".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.created[0].name, "latest_rates_2");
assert_eq!(renamed.findings[0].code, "operation_name_renamed"); 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); 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")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { 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(())
}
}
+2
View File
@@ -0,0 +1,2 @@
#[path = "integration/request_context.rs"]
mod request_context;
+9
View File
@@ -3,6 +3,7 @@ name = "mcp-server"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true
version.workspace = true version.workspace = true
[[bin]] [[bin]]
@@ -15,10 +16,12 @@ axum.workspace = true
base64.workspace = true base64.workspace = true
crank-community-mcp = { path = "../../crates/crank-community-mcp" } crank-community-mcp = { path = "../../crates/crank-community-mcp" }
crank-core = { path = "../../crates/crank-core" } crank-core = { path = "../../crates/crank-core" }
crank-observability = { path = "../../crates/crank-observability" }
crank-registry = { path = "../../crates/crank-registry" } crank-registry = { path = "../../crates/crank-registry" }
crank-runtime = { path = "../../crates/crank-runtime" } crank-runtime = { path = "../../crates/crank-runtime" }
crank-schema = { path = "../../crates/crank-schema" } crank-schema = { path = "../../crates/crank-schema" }
futures-util = "0.3" futures-util = "0.3"
metrics.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
@@ -34,4 +37,10 @@ uuid.workspace = true
crank-mapping = { path = "../../crates/crank-mapping" } crank-mapping = { path = "../../crates/crank-mapping" }
crank-schema = { path = "../../crates/crank-schema" } crank-schema = { path = "../../crates/crank-schema" }
crank-test-support = { path = "../../crates/crank-test-support" } 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 reqwest.workspace = true
tower.workspace = true
tracing-opentelemetry.workspace = true
+73 -23
View File
@@ -1,25 +1,53 @@
use std::{env, net::SocketAddr, time::Duration}; use std::{env, net::SocketAddr, time::Duration};
use crank_community_mcp::{ use crank_community_mcp::{
auth::CommunityMachineCredentialVerifier, build_app, session::PostgresTransportSessionStore, auth::CommunityMachineCredentialVerifier, build_app_with_background_workers_and_limits,
session::PostgresTransportSessionStore,
};
use crank_observability::{
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
capture_critical_error,
}; };
use crank_registry::{PostgresPoolConfig, PostgresRegistry}; use crank_registry::{PostgresPoolConfig, PostgresRegistry};
use crank_runtime::{ use crank_runtime::{
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
RuntimeLimits, SecretCrypto, community_default, RuntimeLimits, SecretCrypto,
}; };
use sqlx::postgres::PgConnectOptions; use sqlx::{PgPool, postgres::PgConnectOptions};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::info; use tracing::info;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt() let observability = crank_observability::init(ObservabilityConfig::from_env(
.with_env_filter( "mcp-server",
env::var("CRANK_LOG_LEVEL") env!("CARGO_PKG_VERSION"),
.unwrap_or_else(|_| "mcp_server=info,tower_http=info".into()), "mcp_server=info,tower_http=info",
) )?)?;
.init(); 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 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(); 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 cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
let api_rate_limit = mcp_api_rate_limit_config_from_env()?; let api_rate_limit = mcp_api_rate_limit_config_from_env()?;
let database_options = database_options_from_env()?; let database_options = database_options_from_env()?;
let registry = PostgresRegistry::connect_with_options_and_pool_config( let registry =
database_options.clone(), PostgresRegistry::connect_with_options_and_pool_config(database_options, pool_config)
pool_config, .await?;
) if metrics_enabled {
.await?; spawn_postgres_pool_metrics(registry.pool().clone());
let session_store = PostgresTransportSessionStore::connect_with_options_and_pool_config( }
database_options, let session_store = PostgresTransportSessionStore::from_pool(registry.pool().clone()).await?;
pool_config,
)
.await?;
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
let runtime = community_default() let runtime = crank_runtime::community_from_env()?
.with_limits(runtime_limits) .with_limits(runtime_limits)
.with_response_cache(cache_stores.response.clone()) .with_response_cache(cache_stores.response.clone())
.with_coordination_store(cache_stores.coordination.clone()) .with_coordination_store(cache_stores.coordination.clone())
.build(); .build();
let app = build_app( let app = build_app_with_background_workers_and_limits(
registry, registry,
refresh_interval, refresh_interval,
base_url, base_url,
@@ -65,11 +90,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
cache_stores.coordination.clone(), cache_stores.coordination.clone(),
std::sync::Arc::new(session_store), std::sync::Arc::new(session_store),
std::sync::Arc::new(CommunityMachineCredentialVerifier), std::sync::Arc::new(CommunityMachineCredentialVerifier),
runtime_limits.max_concurrent_sessions,
); );
let listener = TcpListener::bind(socket_addr).await?; let listener = TcpListener::bind(socket_addr).await?;
info!( info!(
name: "mcp.postgres_pool.configured",
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary, 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_rps = api_rate_limit.requests_per_second,
mcp_rate_limit_burst = api_rate_limit.burst, mcp_rate_limit_burst = api_rate_limit.burst,
cache_backend = %cache_config.backend, 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, max_lifetime_ms = pool_config.max_lifetime_ms,
"postgres pool configured" "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(()) Ok(())
} }
@@ -122,3 +162,13 @@ fn mcp_api_rate_limit_config_from_env() -> Result<RequestRateLimitConfig, Box<dy
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?) 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());
}
});
}
+227
View File
@@ -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(&registry, &operation, "sales-dc08").await;
let api_key = create_platform_api_key(
&registry,
"sales-dc08",
"mcp-dc08",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
install_history_failure_trigger(&registry).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
View File
@@ -1,5 +1,6 @@
mod integration { mod integration {
mod catalog_access; mod catalog_access;
mod common; mod common;
mod tool_search;
mod transport_protocol; mod transport_protocol;
} }
@@ -142,7 +142,10 @@ fn build_test_app_with_store(
refresh_interval, refresh_interval,
public_base_url, public_base_url,
SecretCrypto::new("test-master-key").unwrap(), SecretCrypto::new("test-master-key").unwrap(),
RuntimeExecutor::new(), crank_runtime::community_with_outbound_policy(
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
)
.build(),
RequestRateLimiter::new(rate_limit_config), RequestRateLimiter::new(rate_limit_config),
std::sync::Arc::new(InMemoryCoordinationStateStore::default()), std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
sessions, sessions,
@@ -89,11 +89,16 @@ async fn approval_key_lists_and_decides_pending_requests() {
let approved = client let approved = client
.post(&approve_url) .post(&approve_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}")) .header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.header("x-request-id", "req_approval_execute_123")
.json(&json!({ "approve": "yes", "note": "confirmed by test" })) .json(&json!({ "approve": "yes", "note": "confirmed by test" }))
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(approved.status(), reqwest::StatusCode::OK); 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(); let approved_body = approved.json::<Value>().await.unwrap();
assert_eq!( assert_eq!(
approved_body["approval"]["status"], approved_body["approval"]["status"],
@@ -169,6 +174,10 @@ async fn approval_key_lists_and_decides_pending_requests() {
.await .await
.unwrap(); .unwrap();
assert_eq!(logs.len(), 1); assert_eq!(logs.len(), 1);
assert_eq!(
logs[0].log.request_id.as_deref(),
Some("req_approval_execute_123")
);
} }
#[tokio::test] #[tokio::test]
@@ -401,22 +410,23 @@ async fn tool_call_with_approval_policy_creates_pending_request() {
let mcp_url = agent_mcp_url(&base_url, "sales-gated"); let mcp_url = agent_mcp_url(&base_url, "sales-gated");
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; 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( let tool_result = post_jsonrpc(
&client, &client,
&mcp_url, &mcp_url,
&api_key, &api_key,
Some(&initialized_session), Some(&initialized_session),
json!({ tool_call.clone(),
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "crm_requires_human_approval",
"arguments": {
"email": "ada@example.com"
}
}
}),
) )
.await; .await;
@@ -430,6 +440,19 @@ async fn tool_call_with_approval_policy_creates_pending_request() {
.unwrap(); .unwrap();
assert!(approval_id.starts_with("approval_")); 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 approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated"));
let pending = client let pending = client
.get(&approvals_url) .get(&approvals_url)
@@ -448,6 +471,238 @@ async fn tool_call_with_approval_policy_creates_pending_request() {
); );
} }
#[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(&registry, &operation, "sales-approval-rate-limit").await;
let approval_key = create_approval_platform_api_key(
&registry,
"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(&registry, &operation, "sales-approval-session-rate-limit").await;
let approval_key = create_approval_platform_api_key(
&registry,
"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(&registry, &operation, "sales-interrupted-approval").await;
let approval_key_name = "approval-interrupted";
create_approval_platform_api_key(&registry, "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] #[tokio::test]
async fn elicitation_approval_requires_client_capability() { async fn elicitation_approval_requires_client_capability() {
let registry = test_registry().await; let registry = test_registry().await;
+23 -5
View File
@@ -18,7 +18,7 @@ use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod, Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
WorkspaceId, ToolSelectionPolicy, WorkspaceId,
}; };
use crank_mapping::{MappingRule, MappingSet}; use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{ use crank_registry::{
@@ -45,7 +45,7 @@ use crank_community_mcp::{
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore}, session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
}; };
fn test_workspace_id() -> WorkspaceId { pub(super) fn test_workspace_id() -> WorkspaceId {
WorkspaceId::new("ws_default") WorkspaceId::new("ws_default")
} }
@@ -93,7 +93,7 @@ fn test_agent_id(agent_slug: &str) -> AgentId {
AgentId::new(format!("agent_{agent_slug}")) AgentId::new(format!("agent_{agent_slug}"))
} }
fn build_test_app( pub(super) fn build_test_app(
registry: PostgresRegistry, registry: PostgresRegistry,
refresh_interval: Duration, refresh_interval: Duration,
public_base_url: Option<String>, public_base_url: Option<String>,
@@ -135,7 +135,10 @@ fn build_test_app_with_store(
refresh_interval, refresh_interval,
public_base_url, public_base_url,
SecretCrypto::new("test-master-key").unwrap(), SecretCrypto::new("test-master-key").unwrap(),
RuntimeExecutor::new(), crank_runtime::community_with_outbound_policy(
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
)
.build(),
RequestRateLimiter::new(rate_limit_config), RequestRateLimiter::new(rate_limit_config),
std::sync::Arc::new(InMemoryCoordinationStateStore::default()), std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
sessions, sessions,
@@ -360,6 +363,21 @@ pub(super) async fn publish_agent_with_bindings(
registry: &PostgresRegistry, registry: &PostgresRegistry,
agent_slug: &str, agent_slug: &str,
bindings: Vec<AgentOperationBinding>, 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_id = AgentId::new(format!("agent_{agent_slug}"));
let agent = Agent { let agent = Agent {
@@ -380,7 +398,7 @@ pub(super) async fn publish_agent_with_bindings(
version: 1, version: 1,
status: AgentStatus::Draft, status: AgentStatus::Draft,
instructions: json!({}), instructions: json!({}),
tool_selection_policy: json!({}), tool_selection_policy,
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
}; };
@@ -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(&registry, &operation, "stage-agent").await;
let api_key = create_platform_api_key(
&registry,
"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(
&registry,
"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(
&registry,
"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::{ use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod, Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, 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_mapping::{MappingRule, MappingSet};
use crank_registry::{ use crank_registry::{
@@ -37,7 +38,8 @@ use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::time::sleep; use tokio::time::sleep;
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*}; use tracing_subscriber::fmt::MakeWriter;
use uuid::Version;
use crank_community_mcp::{ use crank_community_mcp::{
auth::{CommunityMachineCredentialVerifier, SharedMachineCredentialVerifier}, auth::{CommunityMachineCredentialVerifier, SharedMachineCredentialVerifier},
@@ -45,6 +47,9 @@ use crank_community_mcp::{
catalog::PublishedToolCatalog, catalog::PublishedToolCatalog,
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore}, session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
}; };
use crank_observability::{
ObservabilityConfig, RedactionLimits, ServiceIdentity, build_subscriber,
};
fn test_workspace_id() -> WorkspaceId { fn test_workspace_id() -> WorkspaceId {
WorkspaceId::new("ws_default") WorkspaceId::new("ws_default")
@@ -136,7 +141,10 @@ fn build_test_app_with_store(
refresh_interval, refresh_interval,
public_base_url, public_base_url,
SecretCrypto::new("test-master-key").unwrap(), SecretCrypto::new("test-master-key").unwrap(),
RuntimeExecutor::new(), crank_runtime::community_with_outbound_policy(
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
)
.build(),
RequestRateLimiter::new(rate_limit_config), RequestRateLimiter::new(rate_limit_config),
std::sync::Arc::new(InMemoryCoordinationStateStore::default()), std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
sessions, sessions,
@@ -400,7 +408,10 @@ async fn generates_request_id_for_tool_call_responses_and_logs() {
.to_str() .to_str()
.unwrap() .unwrap()
.to_owned(); .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(); let call_result = response.json::<Value>().await.unwrap();
assert_eq!(call_result["result"]["isError"], false); 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())); 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() { async fn emits_request_id_in_mcp_ingress_logs() {
let registry = test_registry().await; let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await; let upstream_base_url = spawn_upstream_server().await;
@@ -452,6 +463,18 @@ async fn emits_request_id_in_mcp_ingress_logs() {
) )
.await; .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( let base_url = spawn_mcp_server(build_test_app(
registry, registry,
Duration::from_millis(0), Duration::from_millis(0),
@@ -460,18 +483,7 @@ async fn emits_request_id_in_mcp_ingress_logs() {
.await; .await;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "sales-request-trace"); 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( let response = post_jsonrpc_response(
&client, &client,
&mcp_url, &mcp_url,
@@ -496,14 +508,16 @@ async fn emits_request_id_in_mcp_ingress_logs() {
assert_eq!(response.status(), reqwest::StatusCode::OK); assert_eq!(response.status(), reqwest::StatusCode::OK);
let logs = writer.output(); let logs = writer.output();
assert!( let event = logs
logs.contains("mcp request received"), .lines()
"captured logs did not include ingress marker: {logs}" .filter_map(|line| serde_json::from_str::<Value>(line).ok())
); .find(|event| event["event"] == "mcp.request.received")
assert!(logs.contains("req_mcp_trace_123")); .unwrap();
assert!(logs.contains("sales-request-trace")); assert_eq!(event["service"], "mcp-server");
assert!(logs.contains("default")); assert_eq!(event["request_id"], "req_mcp_trace_123");
assert!(logs.contains("initialize")); 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] #[tokio::test]
@@ -799,11 +813,30 @@ async fn get_requires_session_header() {
.get(agent_mcp_url(&base_url, "sales-get-sse-missing")) .get(agent_mcp_url(&base_url, "sales-get-sse-missing"))
.header(header::ACCEPT, "text/event-stream") .header(header::ACCEPT, "text/event-stream")
.header(header::AUTHORIZATION, format!("Bearer {api_key}")) .header(header::AUTHORIZATION, format!("Bearer {api_key}"))
.header("x-request-id", "req_early_mcp_error")
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); 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] #[tokio::test]
+4
View File
@@ -0,0 +1,4 @@
#[path = "integration/common.rs"]
mod common;
#[path = "integration/request_context.rs"]
mod request_context;
+97
View File
@@ -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;
}
+1 -1
View File
@@ -246,7 +246,7 @@ body.page-leaving .ws-setup-body {
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); } .mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
/* ── Responsive breakpoints ── */ /* ── Responsive breakpoints ── */
@media (max-width: 720px) { @media (max-width: 980px) {
.navbar { padding: 0 16px; } .navbar { padding: 0 16px; }
.nav-links { display: none; } .nav-links { display: none; }
.nav-hamburger { display: flex; } .nav-hamburger { display: flex; }
+171
View File
@@ -1403,6 +1403,177 @@
.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; } .agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; }
.agents-rec-callout strong { color: var(--text-primary); } .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 Settings — Members enhanced
+117 -1
View File
@@ -467,7 +467,11 @@
cursor: pointer; cursor: pointer;
position: relative; position: relative;
z-index: 1; z-index: 1;
transition: background 0.15s; text-align: left;
transition:
background 0.15s,
border-color 0.15s,
box-shadow 0.15s;
border: 1px solid transparent; border: 1px solid transparent;
} }
@@ -1824,6 +1828,118 @@
cursor: wait; cursor: wait;
} }
@media (max-width: 900px) {
.progress-strip {
padding: 0 16px;
gap: 12px;
}
.wizard-body {
display: grid;
padding: 24px 16px 120px;
}
.step-sidebar {
position: static;
width: auto;
flex-basis: auto;
}
.step-sidebar-card {
border-radius: 10px;
}
.step-sidebar-header {
padding: 14px 16px 12px;
}
.steps-list {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.steps-list::before {
left: calc((100% - 32px) / 10);
right: calc((100% - 32px) / 10);
top: 27px;
bottom: auto;
width: auto;
height: 1px;
}
.step-item {
display: grid;
justify-items: center;
align-content: start;
gap: 8px;
min-height: 96px;
padding: 10px 6px 9px;
text-align: center;
}
.sidebar-help {
display: none;
}
.step-content {
width: 100%;
padding-top: 0;
}
.step-number {
margin-bottom: 3px;
font-size: 9.5px;
}
.step-name {
display: -webkit-box;
min-height: 28px;
overflow: hidden;
white-space: normal;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 11px;
line-height: 1.25;
}
.step-status-text {
margin-top: 3px;
font-size: 10px;
line-height: 1.2;
}
.action-bar-inner {
padding: 0 16px;
}
}
@media (max-width: 560px) {
.progress-label,
.progress-pct,
.btn-save-draft,
.step-counter {
display: none;
}
.step-item {
min-height: 88px;
padding: 9px 4px 8px;
}
.step-number {
font-size: 9px;
}
.step-name {
font-size: 10px;
}
.step-status-text {
font-size: 9.5px;
}
}
/* /*
HTTP method picker (Step 4 REST) HTTP method picker (Step 4 REST)
*/ */
+5
View File
@@ -37,7 +37,11 @@
color: var(--text-muted); color: var(--text-muted);
text-decoration: none; text-decoration: none;
padding: 6px 10px; padding: 6px 10px;
border: 0;
background: none;
border-radius: 6px; border-radius: 6px;
font-family: inherit;
cursor: pointer;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
} }
.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); } .ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); }
@@ -107,6 +111,7 @@
.ws-color-swatch { .ws-color-swatch {
width: 20px; width: 20px;
height: 20px; height: 20px;
padding: 0;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
border: 2px solid transparent; border: 2px solid transparent;
+105 -7
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Agents</title> <title>Crank — Agents</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css"> <link rel="stylesheet" href="css/pages.css">
@@ -49,12 +49,12 @@
<div class="user-dropdown-name">Crank</div> <div class="user-dropdown-name">Crank</div>
<div class="user-dropdown-role" id="user-ws-role"></div> <div class="user-dropdown-role" id="user-ws-role"></div>
</div> </div>
<button class="user-dropdown-item" onclick="window.location.href='/settings'"> <a class="user-dropdown-item" href="/settings">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg> <svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span> <span data-i18n="nav.settings">Settings</span>
</button> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()"> <button class="user-dropdown-item danger" @click="window.CrankAuth.logout()">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg> <svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span> <span data-i18n="nav.logout">Log out</span>
</button> </button>
@@ -359,24 +359,122 @@
<!-- Footer count --> <!-- Footer count -->
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0"> <div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
<span x-text="tfKey('agents.drawer.ops_selected', { count: form.selectedOps.length })"></span> <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>
</div> </div>
<!-- Recommendation callout --> <!-- 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> <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> <span x-text="tfKey('agents.drawer.recommendation', { count: form.selectedOps.length })">You've selected tools.</span>
</div> </div>
</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 --> </div><!-- /drawer-body -->
<!-- Drawer footer --> <!-- Drawer footer -->
<div class="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-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;" <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()" @click="saveAgent()"
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')"> x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
Create agent Create agent
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Agent Keys</title> <title>Crank — Agent Keys</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css"> <link rel="stylesheet" href="css/pages.css">
+2 -2
View File
@@ -1,11 +1,11 @@
<div class="field-group" style="margin-top:8px;"> <div class="field-group" style="margin-top:8px;">
<label class="field-label">Interface language</label> <label class="field-label">Interface language</label>
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;"> <div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
<button class="lang-btn" data-lang="en" onclick="setLang('en')"> <button class="lang-btn" data-lang="en">
<span class="lang-flag">🇬🇧</span> <span class="lang-flag">🇬🇧</span>
<span data-i18n="settings.lang.en">English</span> <span data-i18n="settings.lang.en">English</span>
</button> </button>
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')"> <button class="lang-btn" data-lang="ru">
<span class="lang-flag">🇷🇺</span> <span class="lang-flag">🇷🇺</span>
<span data-i18n="settings.lang.ru">Русский</span> <span data-i18n="settings.lang.ru">Русский</span>
</button> </button>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Sign in</title> <title>Crank — Sign in</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/login.css"> <link rel="stylesheet" href="css/login.css">
<script src="%CRANK_BUNDLE_LOGIN%"></script> <script src="%CRANK_BUNDLE_LOGIN%"></script>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Logs</title> <title>Crank — Logs</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css"> <link rel="stylesheet" href="css/pages.css">
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Secrets</title> <title>Crank — Secrets</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css"> <link rel="stylesheet" href="css/pages.css">
+3 -3
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Settings</title> <title>Crank — Settings</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css"> <link rel="stylesheet" href="css/pages.css">
@@ -172,11 +172,11 @@
<label class="field-label" data-i18n="settings.lang.title">Language</label> <label class="field-label" data-i18n="settings.lang.title">Language</label>
<div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div> <div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div>
<div class="lang-switcher"> <div class="lang-switcher">
<button class="lang-btn" data-lang="en" type="button" onclick="setLang('en')"> <button class="lang-btn" data-lang="en" type="button">
<span class="lang-flag">🇬🇧</span> <span class="lang-flag">🇬🇧</span>
<span data-i18n="settings.lang.en">English</span> <span data-i18n="settings.lang.en">English</span>
</button> </button>
<button class="lang-btn" data-lang="ru" type="button" onclick="setLang('ru')"> <button class="lang-btn" data-lang="ru" type="button">
<span class="lang-flag">🇷🇺</span> <span class="lang-flag">🇷🇺</span>
<span data-i18n="settings.lang.ru">Русский</span> <span data-i18n="settings.lang.ru">Русский</span>
</button> </button>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Usage</title> <title>Crank — Usage</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css"> <link rel="stylesheet" href="css/pages.css">
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — New Operation</title> <title>Crank — New Operation</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="../../css/fonts.css">
<link rel="stylesheet" href="../../css/variables.css"> <link rel="stylesheet" href="../../css/variables.css">
<link rel="stylesheet" href="../../css/layout.css"> <link rel="stylesheet" href="../../css/layout.css">
<link rel="stylesheet" href="../../css/wizard.css"> <link rel="stylesheet" href="../../css/wizard.css">
+9 -9
View File
@@ -30,7 +30,7 @@
<!-- Searchable combobox --> <!-- Searchable combobox -->
<div class="upstream-combobox" id="upstream-combobox"> <div class="upstream-combobox" id="upstream-combobox">
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" onclick="toggleUpstreamDropdown(event)"> <div class="upstream-combobox-trigger" id="upstream-combobox-trigger" data-wizard-action="toggle-upstream">
<div class="upstream-combobox-value" id="upstream-combobox-value"> <div class="upstream-combobox-value" id="upstream-combobox-value">
<span class="upstream-combobox-placeholder" data-i18n="wizard.step2.upstream_placeholder">Выберите API-хост…</span> <span class="upstream-combobox-placeholder" data-i18n="wizard.step2.upstream_placeholder">Выберите API-хост…</span>
</div> </div>
@@ -45,7 +45,7 @@
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round"> <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round">
<circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/> <circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/>
</svg> </svg>
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" oninput="filterUpstreams(this.value)" autocomplete="off" spellcheck="false"> <input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" autocomplete="off" spellcheck="false">
</div> </div>
<div class="upstream-dropdown-list" id="upstream-dropdown-list"> <div class="upstream-dropdown-list" id="upstream-dropdown-list">
<!-- populated by JS --> <!-- populated by JS -->
@@ -58,11 +58,11 @@
<div class="upstream-preview-name" id="upstream-preview-name"></div> <div class="upstream-preview-name" id="upstream-preview-name"></div>
<div class="upstream-preview-url" id="upstream-preview-url"></div> <div class="upstream-preview-url" id="upstream-preview-url"></div>
<span class="upstream-auth-badge" id="upstream-preview-badge"></span> <span class="upstream-auth-badge" id="upstream-preview-badge"></span>
<button class="upstream-preview-change" onclick="beginEditSelectedUpstream(event)" data-i18n="wizard.step2.change">Изменить</button> <button class="upstream-preview-change" data-wizard-action="edit-upstream" data-i18n="wizard.step2.change">Изменить</button>
</div> </div>
<!-- Register new upstream trigger row --> <!-- Register new upstream trigger row -->
<div class="upstream-new-trigger" id="upstream-new-trigger" onclick="startNewUpstream()"> <div class="upstream-new-trigger" id="upstream-new-trigger" data-wizard-action="new-upstream">
<div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio"> <div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio">
<div class="upstream-new-trigger-dot"></div> <div class="upstream-new-trigger-dot"></div>
</div> </div>
@@ -89,7 +89,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" data-i18n="wizard.step2.auth_selector">Авторизация API-хоста</label> <label class="form-label" data-i18n="wizard.step2.auth_selector">Авторизация API-хоста</label>
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select" onchange="updateUpstreamAuthUi()"> <select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select">
<option value="none" data-i18n="wizard.step2.auth_mode.none">Без авторизации</option> <option value="none" data-i18n="wizard.step2.auth_mode.none">Без авторизации</option>
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Использовать существующий профиль авторизации</option> <option value="existing" data-i18n="wizard.step2.auth_mode.existing">Использовать существующий профиль авторизации</option>
<option value="create" data-i18n="wizard.step2.auth_mode.create">Создать профиль авторизации сейчас</option> <option value="create" data-i18n="wizard.step2.auth_mode.create">Создать профиль авторизации сейчас</option>
@@ -118,7 +118,7 @@
<div class="form-row" style="grid-template-columns: 1fr 1fr;"> <div class="form-row" style="grid-template-columns: 1fr 1fr;">
<div class="form-group"> <div class="form-group">
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Тип авторизации</span> <span class="form-label-required" data-i18n="workspace_setup.required">обязательно</span></label> <label class="form-label"><span data-i18n="wizard.step2.profile_kind">Тип авторизации</span> <span class="form-label-required" data-i18n="workspace_setup.required">обязательно</span></label>
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select" onchange="updateAuthProfileCreateUi()"> <select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select">
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer-токен</option> <option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer-токен</option>
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Логин и пароль</option> <option value="basic" data-i18n="wizard.step2.auth_kind.basic">Логин и пароль</option>
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API-ключ в заголовке</option> <option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API-ключ в заголовке</option>
@@ -149,7 +149,7 @@
<select class="form-select" id="new-auth-profile-secret-id" data-testid="wizard-auth-secret-select"></select> <select class="form-select" id="new-auth-profile-secret-id" data-testid="wizard-auth-secret-select"></select>
</div> </div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;"> <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" onclick="openQuickSecretModal(event)" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button> <button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" data-wizard-action="quick-secret" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button>
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Открыть страницу секретов</a> <a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Открыть страницу секретов</a>
</div> </div>
</div> </div>
@@ -168,8 +168,8 @@
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Необязательные заголовки без секретных значений. Токены, пароли и ключи храните через профиль авторизации.</div> <div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Необязательные заголовки без секретных значений. Токены, пароли и ключи храните через профиль авторизации.</div>
</div> </div>
<div style="display:flex; gap:8px; margin-top:4px;"> <div style="display:flex; gap:8px; margin-top:4px;">
<button class="btn-primary-sm" onclick="saveNewUpstream(event)" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button> <button class="btn-primary-sm" data-wizard-action="save-upstream" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button>
<button class="btn-ghost-sm" onclick="cancelNewUpstream(event)" data-i18n="btn.cancel">Отмена</button> <button class="btn-ghost-sm" data-wizard-action="cancel-upstream" data-i18n="btn.cancel">Отмена</button>
</div> </div>
</div> </div>
</div> </div>
+5 -5
View File
@@ -25,23 +25,23 @@
</div> </div>
<div class="config-card-body"> <div class="config-card-body">
<div class="method-grid"> <div class="method-grid">
<button class="method-card" data-method="GET" onclick="selectMethod(this)"> <button class="method-card" data-method="GET">
<span class="method-name">GET</span> <span class="method-name">GET</span>
<span class="method-desc" data-i18n="wizard.step3.rest.read">Чтение</span> <span class="method-desc" data-i18n="wizard.step3.rest.read">Чтение</span>
</button> </button>
<button class="method-card active" data-method="POST" onclick="selectMethod(this)"> <button class="method-card active" data-method="POST">
<span class="method-name">POST</span> <span class="method-name">POST</span>
<span class="method-desc" data-i18n="wizard.step3.rest.create">Создание</span> <span class="method-desc" data-i18n="wizard.step3.rest.create">Создание</span>
</button> </button>
<button class="method-card" data-method="PUT" onclick="selectMethod(this)"> <button class="method-card" data-method="PUT">
<span class="method-name">PUT</span> <span class="method-name">PUT</span>
<span class="method-desc" data-i18n="wizard.step3.rest.replace">Замена</span> <span class="method-desc" data-i18n="wizard.step3.rest.replace">Замена</span>
</button> </button>
<button class="method-card" data-method="PATCH" onclick="selectMethod(this)"> <button class="method-card" data-method="PATCH">
<span class="method-name">PATCH</span> <span class="method-name">PATCH</span>
<span class="method-desc" data-i18n="wizard.step3.rest.update">Обновление</span> <span class="method-desc" data-i18n="wizard.step3.rest.update">Обновление</span>
</button> </button>
<button class="method-card" data-method="DELETE" onclick="selectMethod(this)"> <button class="method-card" data-method="DELETE">
<span class="method-name">DELETE</span> <span class="method-name">DELETE</span>
<span class="method-desc" data-i18n="wizard.step3.rest.remove">Удаление</span> <span class="method-desc" data-i18n="wizard.step3.rest.remove">Удаление</span>
</button> </button>
+16 -16
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Workspace</title> <title>Crank — Workspace</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css"> <link rel="stylesheet" href="css/pages.css">
@@ -24,10 +24,10 @@
</div> </div>
Crank Crank
</a> </a>
<a href="javascript:history.back()" class="ws-setup-back"> <button type="button" class="ws-setup-back" data-history-back>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg> <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
<span data-i18n="workspace_setup.back">Back</span> <span data-i18n="workspace_setup.back">Back</span>
</a> </button>
</div> </div>
<!-- Body --> <!-- Body -->
@@ -48,27 +48,27 @@
<div> <div>
<div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div> <div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div>
<div class="ws-color-swatches"> <div class="ws-color-swatches">
<div class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" onclick="pickColor(this)" title="Teal"></div> <button class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" type="button" title="Teal"></button>
<div class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" onclick="pickColor(this)" title="Purple"></div> <button class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" type="button" title="Purple"></button>
<div class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" onclick="pickColor(this)" title="Cyan"></div> <button class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" type="button" title="Cyan"></button>
<div class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" onclick="pickColor(this)" title="Amber"></div> <button class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" type="button" title="Amber"></button>
<div class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" onclick="pickColor(this)" title="Green"></div> <button class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" type="button" title="Green"></button>
<div class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" onclick="pickColor(this)" title="Red"></div> <button class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" type="button" title="Red"></button>
<div class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" onclick="pickColor(this)" title="Indigo"></div> <button class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" type="button" title="Indigo"></button>
</div> </div>
</div> </div>
</div> </div>
<div class="form-group" style="margin-bottom:14px;"> <div class="form-group" style="margin-bottom:14px;">
<label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label> <label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off" oninput="onWsNameInput(this.value)"> <input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off">
</div> </div>
<div class="form-group" style="margin-bottom:14px;"> <div class="form-group" style="margin-bottom:14px;">
<label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label> <label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
<div style="position:relative;"> <div style="position:relative;">
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span> <span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;" oninput="onWsSlugInput(this.value)"> <input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;">
</div> </div>
<div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div> <div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
</div> </div>
@@ -81,8 +81,8 @@
<!-- ── Actions ── --> <!-- ── Actions ── -->
<div class="ws-setup-actions"> <div class="ws-setup-actions">
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</a> <button type="button" class="btn-ghost-sm" data-history-back style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</button>
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" onclick="submitForm()" data-i18n="workspace_setup.actions.save">Save changes</button> <button class="btn-primary ws-submit-btn" id="submit-btn" type="button" data-i18n="workspace_setup.actions.save">Save changes</button>
</div> </div>
<div class="ws-setup-footer-note" id="footer-note" hidden> <div class="ws-setup-footer-note" id="footer-note" hidden>
@@ -97,8 +97,8 @@
</div> </div>
<div class="danger-zone-action"> <div class="danger-zone-action">
<div class="danger-zone-text"> <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-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 JSON snapshot of workspace settings, operations, agents, secrets, usage data and agent access keys.</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> </div>
<button class="btn-danger" id="export-workspace-btn" type="button" data-i18n="workspace_setup.export">Export</button> <button class="btn-danger" id="export-workspace-btn" type="button" data-i18n="workspace_setup.export">Export</button>
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Operations</title> <title>Crank — Operations</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/variables.css"> <link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/catalog.css"> <link rel="stylesheet" href="css/catalog.css">
+192 -20
View File
@@ -14,6 +14,7 @@ function mapAgent(agent) {
raw_status: agent.status, raw_status: agent.status,
operation_count: agent.operation_count || 0, operation_count: agent.operation_count || 0,
operation_ids: agent.operation_ids || [], 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, key_count: agent.key_count || 0,
calls_today: agent.calls_today || 0, calls_today: agent.calls_today || 0,
created_at: agent.created_at, created_at: agent.created_at,
@@ -66,10 +67,18 @@ document.addEventListener('alpine:init', function() {
description: '', description: '',
status: 'published', status: 'published',
selectedOps: [], selectedOps: [],
accessMode: 'direct',
groups: [],
searchMaxResults: 8,
}, },
opSearch: '', opSearch: '',
slugManuallyEdited: false, slugManuallyEdited: false,
searchPreviewQuery: '',
searchPreviewGroup: '',
searchPreviewItems: [],
searchPreviewLoading: false,
searchPreviewRan: false,
async init() { async init() {
var self = this; var self = this;
@@ -205,7 +214,7 @@ document.addEventListener('alpine:init', function() {
get agentToolFindings() { get agentToolFindings() {
var findings = []; var findings = [];
var selected = this.selectedOperations; 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')); findings.push(this.tKey('agents.drawer.finding.too_many_tools'));
} }
@@ -242,13 +251,18 @@ document.addEventListener('alpine:init', function() {
description: '', description: '',
status: 'published', status: 'published',
selectedOps: [], selectedOps: [],
accessMode: 'direct',
groups: [],
searchMaxResults: 8,
}; };
this.opSearch = ''; this.opSearch = '';
this.slugManuallyEdited = false; this.slugManuallyEdited = false;
this.resetSearchPreview();
this.drawerOpen = true; this.drawerOpen = true;
}, },
openEdit(agent) { openEdit(agent) {
var policy = agent.tool_selection_policy || {};
this.drawerMode = 'edit'; this.drawerMode = 'edit';
this.editingId = agent.id; this.editingId = agent.id;
this.form = { this.form = {
@@ -257,9 +271,22 @@ document.addEventListener('alpine:init', function() {
description: agent.description, description: agent.description,
status: agent.raw_status || agent.status || 'draft', status: agent.raw_status || agent.status || 'draft',
selectedOps: [].concat(agent.operation_ids || []), 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.opSearch = '';
this.slugManuallyEdited = true; this.slugManuallyEdited = true;
this.resetSearchPreview();
this.drawerOpen = true; this.drawerOpen = true;
}, },
@@ -289,13 +316,170 @@ document.addEventListener('alpine:init', function() {
this.form.selectedOps.push(operationId); this.form.selectedOps.push(operationId);
} else { } else {
this.form.selectedOps.splice(index, 1); 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) { isOpSelected(operationId) {
return this.form.selectedOps.includes(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) { operationsLookSimilar(left, right) {
var leftTokens = this.operationTokens(left); var leftTokens = this.operationTokens(left);
var rightTokens = this.operationTokens(right); var rightTokens = this.operationTokens(right);
@@ -345,7 +529,7 @@ document.addEventListener('alpine:init', function() {
display_name: this.form.display_name, display_name: this.form.display_name,
description: this.form.description, description: this.form.description,
instructions: {}, instructions: {},
tool_selection_policy: {}, tool_selection_policy: this.toolSelectionPolicy(),
}); });
agentId = created.agent_id; agentId = created.agent_id;
currentVersion = created.version || 1; currentVersion = created.version || 1;
@@ -359,27 +543,15 @@ document.addEventListener('alpine:init', function() {
currentVersion = agent.current_draft_version || 1; currentVersion = agent.current_draft_version || 1;
} }
await window.CrankApi.saveAgentBindings( var savedVersion = await window.CrankApi.saveAgentBindings(
this.workspaceId, this.workspaceId,
agentId, agentId,
this.form.selectedOps.map(function(operationId) { {
var operation = self.operations.find(function(item) { bindings: this.agentBindings(),
return item.id === operationId; tool_selection_policy: this.toolSelectionPolicy(),
}); },
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,
};
}),
); );
currentVersion = savedVersion.version || currentVersion;
if (this.form.status === 'published') { if (this.form.status === 'published') {
await window.CrankApi.publishAgent(this.workspaceId, agentId, { await window.CrankApi.publishAgent(this.workspaceId, agentId, {
+3 -8
View File
@@ -124,14 +124,6 @@
return encoded ? ('?' + encoded) : ''; return encoded ? ('?' + encoded) : '';
} }
function postBytes(path, bytes, fileName) {
return request(API_BASE + path, {
method: 'POST',
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
body: bytes,
});
}
window.CrankApi = { window.CrankApi = {
login: function(payload) { login: function(payload) {
return request(AUTH_BASE + '/login', { return request(AUTH_BASE + '/login', {
@@ -270,6 +262,9 @@
saveAgentBindings: function(workspaceId, agentId, payload) { saveAgentBindings: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/bindings', 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) { publishAgent: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload); return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
}, },
+64 -8
View File
@@ -442,8 +442,8 @@ var TRANSLATIONS = {
'workspace_setup.create.subtitle': 'This Community installation uses one workspace for MCP operations and agents.', '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.create.footer': 'This Community installation uses one workspace.',
'workspace_setup.danger.title': 'Danger zone', 'workspace_setup.danger.title': 'Danger zone',
'workspace_setup.danger.export_title': 'Export all data', 'workspace_setup.danger.export_title': 'Export workspace catalog',
'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_body': 'Download a non-restorable JSON catalog of workspace settings, operation summaries, agent summaries and API key metadata.',
'workspace_setup.export': 'Export', 'workspace_setup.export': 'Export',
'workspace_setup.role.owner': 'Owner', 'workspace_setup.role.owner': 'Owner',
'workspace_setup.role.admin': 'Admin', 'workspace_setup.role.admin': 'Admin',
@@ -819,13 +819,41 @@ var TRANSLATIONS = {
'agents.drawer.operations_sub': 'Select the MCP tools available to this agent.', '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.operations_sub_community': 'Select the MCP tools available to this agent.',
'agents.drawer.finding.title': 'Recommendation', '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.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.filter_ops': 'Filter operations…',
'agents.drawer.ops_no_match': 'No operations match "{query}"', 'agents.drawer.ops_no_match': 'No operations match "{query}"',
'agents.drawer.ops_selected': '{count} operations selected', 'agents.drawer.ops_selected': '{count} operations selected',
'agents.drawer.clear_all': 'Clear all', '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.cancel': 'Cancel',
'agents.drawer.create': 'Create agent', 'agents.drawer.create': 'Create agent',
'agents.drawer.save': 'Save changes', 'agents.drawer.save': 'Save changes',
@@ -1306,8 +1334,8 @@ var TRANSLATIONS = {
'workspace_setup.create.subtitle': 'В Community используется один воркспейс для MCP-операций и агентов.', 'workspace_setup.create.subtitle': 'В Community используется один воркспейс для MCP-операций и агентов.',
'workspace_setup.create.footer': 'В Community используется один воркспейс.', 'workspace_setup.create.footer': 'В Community используется один воркспейс.',
'workspace_setup.danger.title': 'Опасная зона', 'workspace_setup.danger.title': 'Опасная зона',
'workspace_setup.danger.export_title': 'Экспортировать все данные', 'workspace_setup.danger.export_title': 'Экспорт каталога рабочего пространства',
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, операций, агентов, секретов, данных использования и ключей доступа агентов.', 'workspace_setup.danger.export_body': 'Скачать невосстанавливаемый JSON-каталог настроек рабочего пространства, сводок операций и агентов, а также метаданных ключей API.',
'workspace_setup.export': 'Экспорт', 'workspace_setup.export': 'Экспорт',
'workspace_setup.role.owner': 'Владелец', 'workspace_setup.role.owner': 'Владелец',
'workspace_setup.role.admin': 'Администратор', 'workspace_setup.role.admin': 'Администратор',
@@ -1683,13 +1711,41 @@ var TRANSLATIONS = {
'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.', 'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.', 'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
'agents.drawer.finding.title': 'Рекомендация', '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.finding.similar_tools': 'Инструменты «{left}» и «{right}» похожи. Переименуйте их точнее или оставьте один вариант.',
'agents.drawer.filter_ops': 'Фильтр операций…', 'agents.drawer.filter_ops': 'Фильтр операций…',
'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"', 'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"',
'agents.drawer.ops_selected': 'Выбрано операций: {count}', 'agents.drawer.ops_selected': 'Выбрано операций: {count}',
'agents.drawer.clear_all': 'Очистить все', '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.cancel': 'Отмена',
'agents.drawer.create': 'Создать агента', 'agents.drawer.create': 'Создать агента',
'agents.drawer.save': 'Сохранить изменения', 'agents.drawer.save': 'Сохранить изменения',
+40 -4
View File
@@ -8,6 +8,8 @@ document.addEventListener('DOMContentLoaded', function () {
openId: null, openId: null,
liveMode: true, liveMode: true,
timer: null, timer: null,
searchTimer: null,
refreshPromise: null,
workspaceId: null, workspaceId: null,
loading: false, loading: false,
loadError: '', loadError: '',
@@ -457,7 +459,13 @@ document.addEventListener('DOMContentLoaded', function () {
} }
async function refreshOperationalData() { async function refreshOperationalData() {
await Promise.all([loadLogs(), loadApprovals()]); if (state.refreshPromise) {
return state.refreshPromise;
}
state.refreshPromise = Promise.all([loadLogs(), loadApprovals()]).finally(function () {
state.refreshPromise = null;
});
return state.refreshPromise;
} }
async function loadLogDetail(logId) { async function loadLogDetail(logId) {
@@ -494,10 +502,14 @@ document.addEventListener('DOMContentLoaded', function () {
function startPolling() { function startPolling() {
stopPolling(); stopPolling();
if (!state.liveMode) { if (!state.liveMode || document.hidden) {
return; return;
} }
state.timer = setInterval(refreshOperationalData, 4000); state.timer = setTimeout(async function poll() {
state.timer = null;
await refreshOperationalData();
startPolling();
}, 4000);
} }
function toggleLive() { function toggleLive() {
@@ -526,7 +538,13 @@ document.addEventListener('DOMContentLoaded', function () {
if (logSearch) { if (logSearch) {
logSearch.addEventListener('input', function () { logSearch.addEventListener('input', function () {
state.search = this.value.trim(); state.search = this.value.trim();
loadLogs(); if (state.searchTimer) {
clearTimeout(state.searchTimer);
}
state.searchTimer = setTimeout(function () {
state.searchTimer = null;
loadLogs();
}, 250);
}); });
} }
@@ -572,6 +590,24 @@ document.addEventListener('DOMContentLoaded', function () {
refreshOperationalData(); 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(); setLiveState();
startPolling(); startPolling();
+3
View File
@@ -330,6 +330,9 @@ async function loadWorkspaceSettings() {
} }
async function initSettingsPage() { async function initSettingsPage() {
document.querySelectorAll('.lang-btn[data-lang]').forEach(function(button) {
button.addEventListener('click', function() { setLang(button.dataset.lang); });
});
bindSectionNavigation(); bindSectionNavigation();
await loadProfile(); await loadProfile();
await loadCapabilities(); await loadCapabilities();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31
View File
@@ -83,6 +83,7 @@ async function initWizardPage() {
await loadProtocolCapabilities(); await loadProtocolCapabilities();
await loadWizardPanels([1, 2, 3, 4, 5]); await loadWizardPanels([1, 2, 3, 4, 5]);
bindWizardPanelActions();
if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') { if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') {
await window.CrankOverlay.render(document, { await window.CrankOverlay.render(document, {
workspace: workspace, workspace: workspace,
@@ -248,6 +249,36 @@ function selectMethod(btn) {
} }
} }
function bindWizardPanelActions() {
var upstreamSearch = document.getElementById('upstream-search');
var authMode = document.getElementById('new-upstream-auth-mode');
var authKind = document.getElementById('new-auth-profile-kind');
if (upstreamSearch) {
upstreamSearch.addEventListener('input', function(event) {
filterUpstreams(event.target.value);
});
}
if (authMode) authMode.addEventListener('change', updateUpstreamAuthUi);
if (authKind) authKind.addEventListener('change', updateAuthProfileCreateUi);
document.querySelectorAll('.method-card[data-method]').forEach(function(button) {
button.addEventListener('click', function() { selectMethod(button); });
});
document.querySelectorAll('[data-wizard-action]').forEach(function(element) {
element.addEventListener('click', function(event) {
var action = element.dataset.wizardAction;
if (action === 'toggle-upstream') toggleUpstreamDropdown(event);
if (action === 'edit-upstream') beginEditSelectedUpstream(event);
if (action === 'new-upstream') startNewUpstream();
if (action === 'quick-secret') openQuickSecretModal(event);
if (action === 'save-upstream') void saveNewUpstream(event);
if (action === 'cancel-upstream') cancelNewUpstream(event);
});
});
}
function escapeHtml(str) { function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
+12 -1
View File
@@ -196,7 +196,7 @@ async function exportWorkspaceSnapshot() {
var slug = workspaceFormState.workspaceRecord && workspaceFormState.workspaceRecord.workspace var slug = workspaceFormState.workspaceRecord && workspaceFormState.workspaceRecord.workspace
? workspaceFormState.workspaceRecord.workspace.slug ? workspaceFormState.workspaceRecord.workspace.slug
: tKey('settings.nav.workspace'); : tKey('settings.nav.workspace');
downloadJsonFile(slug + '-snapshot.json', snapshot); downloadJsonFile(slug + '-catalog-snapshot.json', snapshot);
} catch (error) { } catch (error) {
if (window.CrankUi) { if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('workspace_setup.export_error'), tKey('workspace_setup.export_error_title')); 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() { async function initPage() {
updatePageMode(); updatePageMode();
document.querySelectorAll('.ws-color-swatch').forEach(function(swatch) {
swatch.addEventListener('click', function() { pickColor(swatch); });
});
document.querySelectorAll('[data-history-back]').forEach(function(button) {
button.addEventListener('click', function() { window.history.back(); });
});
var elements = formElements();
elements.name.addEventListener('input', function(event) { onWsNameInput(event.target.value); });
elements.slug.addEventListener('input', function(event) { onWsSlugInput(event.target.value); });
elements.submit.addEventListener('click', submitForm);
var exportButton = document.getElementById('export-workspace-btn'); var exportButton = document.getElementById('export-workspace-btn');
if (exportButton) { if (exportButton) {
exportButton.addEventListener('click', exportWorkspaceSnapshot); exportButton.addEventListener('click', exportWorkspaceSnapshot);
+7
View File
@@ -6,6 +6,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
location = / { location = / {
try_files /index.html =404; try_files /index.html =404;
} }
+24 -4
View File
@@ -6,8 +6,10 @@
"": { "": {
"name": "crank-ui", "name": "crank-ui",
"dependencies": { "dependencies": {
"@fontsource/inter": "5.2.8",
"@fontsource/jetbrains-mono": "5.2.8",
"alpinejs": "3.15.12", "alpinejs": "3.15.12",
"js-yaml": "5.2.1" "js-yaml": "5.2.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
@@ -456,6 +458,24 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.61.1", "version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
@@ -560,9 +580,9 @@
} }
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "5.2.1", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz",
"integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
+3 -1
View File
@@ -8,8 +8,10 @@
"e2e:headed": "playwright test --headed" "e2e:headed": "playwright test --headed"
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "5.2.8",
"@fontsource/jetbrains-mono": "5.2.8",
"alpinejs": "3.15.12", "alpinejs": "3.15.12",
"js-yaml": "5.2.1" "js-yaml": "5.2.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
+19
View File
@@ -10,6 +10,10 @@ const BRAND_IMAGE_PATHS = [
path.join(ROOT_DIR, 'crank-community.png'), path.join(ROOT_DIR, 'crank-community.png'),
path.resolve(ROOT_DIR, '..', '..', 'crank-community.png'), path.resolve(ROOT_DIR, '..', '..', 'crank-community.png'),
]; ];
const FONT_FILES = [
['@fontsource/inter', 'inter', ['400', '500', '600', '700']],
['@fontsource/jetbrains-mono', 'jetbrains-mono', ['400', '500']],
];
const BUNDLES = { const BUNDLES = {
'protected-core': { 'protected-core': {
@@ -136,6 +140,20 @@ function copyDirectory(source, destination) {
} }
} }
function copyFonts() {
FONT_FILES.forEach(function([packageName, family, weights]) {
weights.forEach(function(weight) {
['latin', 'cyrillic'].forEach(function(subset) {
var fileName = `${family}-${subset}-${weight}-normal.woff2`;
copyFile(
path.join(ROOT_DIR, 'node_modules', packageName, 'files', fileName),
path.join(DIST_DIR, 'fonts', fileName)
);
});
});
});
}
function readSource(relativePath) { function readSource(relativePath) {
return fs.readFileSync(sourcePath(relativePath), 'utf8'); return fs.readFileSync(sourcePath(relativePath), 'utf8');
} }
@@ -191,6 +209,7 @@ async function main() {
} }
copyDirectory(path.join(ROOT_DIR, 'css'), path.join(DIST_DIR, 'css')); copyDirectory(path.join(ROOT_DIR, 'css'), path.join(DIST_DIR, 'css'));
copyFonts();
copyDirectory(path.join(ROOT_DIR, 'data'), path.join(DIST_DIR, 'data')); copyDirectory(path.join(ROOT_DIR, 'data'), path.join(DIST_DIR, 'data'));
ensureDirectory(path.join(DIST_DIR, 'html', 'wizard')); ensureDirectory(path.join(DIST_DIR, 'html', 'wizard'));
[ [
+11 -1
View File
@@ -119,8 +119,18 @@ function proxyRequest(request, response) {
}, },
}, },
(proxyResponse) => { (proxyResponse) => {
if (response.destroyed || response.writableEnded) {
proxyResponse.resume();
return;
}
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers); response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
pipeline(proxyResponse, response, () => {}); proxyResponse.on('error', function() {
if (!response.destroyed) response.destroy();
});
response.on('close', function() {
if (!proxyResponse.destroyed) proxyResponse.destroy();
});
proxyResponse.pipe(response);
}, },
); );
+23
View File
@@ -21,3 +21,26 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
); );
await expect(page.locator('.drawer')).toContainText(/mcp/i); 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();
});
+26 -3
View File
@@ -1,6 +1,29 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { getCurrentWorkspace, login, localized } = require('./helpers'); const { getCurrentWorkspace, login, localized } = require('./helpers');
test('mobile wizard progress connector crosses the indicator centers', async ({ page }) => {
await page.setViewportSize({ width: 720, height: 900 });
await login(page);
await page.goto('/wizard/');
const geometry = await page.locator('.steps-list').evaluate((list) => {
const indicators = [...list.querySelectorAll('.step-indicator')];
const first = indicators[0].getBoundingClientRect();
const last = indicators.at(-1).getBoundingClientRect();
const listRect = list.getBoundingClientRect();
const line = getComputedStyle(list, '::before');
return {
leftDelta: Math.abs(listRect.left + Number.parseFloat(line.left) - (first.left + first.width / 2)),
rightDelta: Math.abs(listRect.right - Number.parseFloat(line.right) - (last.left + last.width / 2)),
topDelta: Math.abs(listRect.top + Number.parseFloat(line.top) - (first.top + first.height / 2)),
};
});
expect(geometry.leftDelta).toBeLessThan(1);
expect(geometry.rightDelta).toBeLessThan(1);
expect(geometry.topDelta).toBeLessThan(1);
});
test('wizard loads and protocol selection updates flow', async ({ page }) => { test('wizard loads and protocol selection updates flow', async ({ page }) => {
await login(page); await login(page);
await page.goto('/wizard/'); await page.goto('/wizard/');
@@ -138,15 +161,15 @@ test('wizard builds visual request mappings from JSON sample and path params', a
await page.goto('/wizard/'); await page.goto('/wizard/');
await page.locator('[data-testid="wizard-protocol-rest"]').click(); 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 expect(page.locator('#step-panel-2')).toBeVisible();
await page.locator('#endpoint-path').fill('/rates/{date}'); 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 expect(page.locator('#step-panel-3-rest')).toBeVisible();
await page.locator('.method-card[data-method="GET"]').click(); 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 expect(page.locator('#step-panel-5')).toBeVisible();
await page.locator('#wizard-input-sample').fill(JSON.stringify({ await page.locator('#wizard-input-sample').fill(JSON.stringify({
@@ -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-members')).toHaveCount(0);
await expect(page.locator('#section-invite')).toHaveCount(0); await expect(page.locator('#section-invite')).toHaveCount(0);
await expect(page.locator('#delete-workspace-btn')).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 page.goto('/settings');
await expect(page.locator('.page-title')).toHaveText(localized('Account settings', 'Настройки аккаунта')); await expect(page.locator('.page-title')).toHaveText(localized('Account settings', 'Настройки аккаунта'));
+8
View File
@@ -3,18 +3,26 @@ name = "crank-adapter-rest"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true
version.workspace = true version.workspace = true
[dependencies] [dependencies]
async-trait = "0.1" async-trait = "0.1"
crank-core = { path = "../crank-core" } crank-core = { path = "../crank-core" }
crank-trace = { path = "../crank-trace" }
futures-util = "0.3" futures-util = "0.3"
metrics.workspace = true
opentelemetry.workspace = true
reqwest = { workspace = true, features = ["stream"] } reqwest = { workspace = true, features = ["stream"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true
tracing-opentelemetry.workspace = true
[dev-dependencies] [dev-dependencies]
axum.workspace = true axum.workspace = true
opentelemetry_sdk.workspace = true
tokio.workspace = true tokio.workspace = true
tracing-subscriber.workspace = true
+434 -21
View File
@@ -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_core::{HttpMethod, RestTarget};
use crank_trace::{ErrorCategory, Stage, StageOutcome};
use futures_util::StreamExt;
use opentelemetry::{global, propagation::Injector, trace::TraceContextExt};
use reqwest::{ use reqwest::{
Client, Client,
dns::{Addrs, Name, Resolve, Resolving},
header::{HeaderMap, HeaderName, HeaderValue}, header::{HeaderMap, HeaderName, HeaderValue},
redirect,
}; };
use serde_json::Value; use serde_json::Value;
use tracing::{Instrument, Span};
use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::{RestAdapterError, RestRequest, RestResponse}; use crate::{RestAdapterError, RestRequest, RestResponse};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RestAdapter { pub struct RestAdapter {
client: Client, client: Result<Client, Arc<str>>,
policy: OutboundHttpPolicy,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutboundHttpPolicy {
allowed_hosts: Vec<String>,
denied_hosts: Vec<String>,
max_response_bytes: usize,
}
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
impl Default for RestAdapter { impl Default for RestAdapter {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -22,20 +45,69 @@ impl Default for RestAdapter {
impl RestAdapter { impl RestAdapter {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self::with_policy(OutboundHttpPolicy::default())
client: Client::new(), }
}
pub fn from_env() -> Result<Self, RestAdapterError> {
Ok(Self::with_policy(OutboundHttpPolicy::from_env()?))
}
pub fn with_policy(policy: OutboundHttpPolicy) -> Self {
let resolver = Arc::new(PolicyDnsResolver {
policy: policy.clone(),
});
let client = Client::builder()
.redirect(redirect::Policy::none())
.no_proxy()
.dns_resolver(resolver)
.build()
.map_err(|error| Arc::<str>::from(error.to_string()));
Self { client, policy }
} }
pub async fn execute( pub async fn execute(
&self, &self,
target: &RestTarget, target: &RestTarget,
request: &RestRequest, 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> { ) -> Result<RestResponse, RestAdapterError> {
let url = build_url(target, request)?; let url = build_url(target, request)?;
let headers = build_headers(target, request)?; self.policy.validate_url(&url)?;
let mut builder = self let mut headers = build_headers(target, request)?;
.client 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) .request(to_reqwest_method(target.method), url)
.headers(headers) .headers(headers)
.timeout(Duration::from_millis(request.timeout_ms)); .timeout(Duration::from_millis(request.timeout_ms));
@@ -44,24 +116,309 @@ impl RestAdapter {
builder = builder.json(body); builder = builder.json(body);
} }
let response = builder.send().await?; let upstream_span = Stage::UpstreamHttp.span();
let status = response.status(); let result = async {
let headers = normalize_headers(response.headers()); let response = builder.send().await?;
let body = decode_body(response).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() { if !status.is_success() {
return Err(RestAdapterError::UnexpectedStatus { return Err(RestAdapterError::UnexpectedStatus {
status: status.as_u16(), status: status.as_u16(),
body,
});
}
Ok(RestResponse {
status_code: status.as_u16(),
headers,
body, 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 { Ok(Self {
status_code: status.as_u16(), allowed_hosts: host_patterns_from_env("CRANK_OUTBOUND_ALLOWED_HOSTS")?,
headers, denied_hosts: host_patterns_from_env("CRANK_OUTBOUND_DENIED_HOSTS")?,
body, max_response_bytes,
}) })
} }
pub fn allowing_hosts(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
allowed_hosts: hosts.into_iter().map(Into::into).collect(),
..Self::default()
}
}
pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
self.max_response_bytes = max_response_bytes;
self
}
pub fn validate_base_url(&self, base_url: &str) -> Result<(), RestAdapterError> {
let url = reqwest::Url::parse(base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
url: base_url.to_owned(),
})?;
self.validate_url(&url)
}
fn validate_url(&self, url: &reqwest::Url) -> Result<(), RestAdapterError> {
if !matches!(url.scheme(), "http" | "https")
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(RestAdapterError::TargetNotAllowed {
target: url.to_string(),
});
}
let host = url
.host_str()
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
target: url.to_string(),
})?;
self.validate_host(host)?;
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
return Err(RestAdapterError::TargetNotAllowed {
target: host.to_owned(),
});
}
if let Ok(address) = host.parse::<IpAddr>()
&& !self.is_explicitly_allowed(host)
&& !is_public_ip(address)
{
return Err(RestAdapterError::TargetNotAllowed {
target: host.to_owned(),
});
}
Ok(())
}
fn validate_host(&self, host: &str) -> Result<(), RestAdapterError> {
let host = normalize_host(host);
let denied = self
.denied_hosts
.iter()
.any(|pattern| host_matches(pattern, &host));
if denied {
return Err(RestAdapterError::TargetNotAllowed { target: host });
}
Ok(())
}
fn is_explicitly_allowed(&self, host: &str) -> bool {
let host = normalize_host(host);
self.allowed_hosts
.iter()
.any(|pattern| host_matches(pattern, &host))
}
}
#[derive(Clone, Debug)]
struct PolicyDnsResolver {
policy: OutboundHttpPolicy,
}
impl Resolve for PolicyDnsResolver {
fn resolve(&self, name: Name) -> Resolving {
let host = normalize_host(name.as_str());
let policy = self.policy.clone();
Box::pin(async move {
policy
.validate_host(&host)
.map_err(|error| boxed_io_error(error.to_string()))?;
let explicitly_allowed = policy.is_explicitly_allowed(&host);
let resolved = tokio::net::lookup_host((host.as_str(), 0))
.await
.map_err(|error| Box::new(error) as Box<dyn std::error::Error + Send + Sync>)?;
let addresses = resolved
.filter(|address| explicitly_allowed || is_public_ip(address.ip()))
.collect::<Vec<SocketAddr>>();
if addresses.is_empty() {
return Err(boxed_io_error(format!(
"outbound target {host} did not resolve to an allowed address"
)));
}
Ok(Box::new(addresses.into_iter()) as Addrs)
})
}
}
fn boxed_io_error(message: String) -> Box<dyn std::error::Error + Send + Sync> {
Box::new(io::Error::new(io::ErrorKind::PermissionDenied, message))
}
fn host_patterns_from_env(name: &str) -> Result<Vec<String>, RestAdapterError> {
let value = match env::var(name) {
Ok(value) => value,
Err(env::VarError::NotPresent) => return Ok(Vec::new()),
Err(error) => {
return Err(RestAdapterError::InvalidConfiguration {
details: error.to_string(),
});
}
};
value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
let wildcard = value.starts_with("*.");
let normalized = normalize_host(value.trim_start_matches("*."));
let valid_ip = !wildcard && normalized.parse::<IpAddr>().is_ok();
if normalized.is_empty()
|| normalized.contains('/')
|| (!valid_ip && normalized.contains(':'))
|| (wildcard && normalized.parse::<IpAddr>().is_ok())
{
return Err(RestAdapterError::InvalidConfiguration {
details: format!("{name} contains an invalid host pattern: {value}"),
});
}
Ok(if wildcard {
format!("*.{normalized}")
} else {
normalized
})
})
.collect()
}
fn normalize_host(host: &str) -> String {
host.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.trim_end_matches('.')
.to_ascii_lowercase()
}
fn is_local_hostname(host: &str) -> bool {
let host = normalize_host(host);
host == "localhost" || host.ends_with(".localhost")
}
fn host_matches(pattern: &str, host: &str) -> bool {
pattern.strip_prefix("*.").map_or_else(
|| pattern == host,
|suffix| host != suffix && host.ends_with(&format!(".{suffix}")),
)
}
fn is_public_ip(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => is_public_ipv4(address),
IpAddr::V6(address) => is_public_ipv6(address),
}
}
fn is_public_ipv4(address: Ipv4Addr) -> bool {
let octets = address.octets();
!(address.is_private()
|| address.is_loopback()
|| address.is_link_local()
|| address.is_broadcast()
|| address.is_documentation()
|| address.is_unspecified()
|| address.is_multicast()
|| octets[0] == 0
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|| (octets[0] == 198 && (18..=19).contains(&octets[1]))
|| octets[0] >= 240)
}
fn is_public_ipv6(address: Ipv6Addr) -> bool {
let segments = address.segments();
if let Some(address) = address.to_ipv4_mapped() {
return is_public_ipv4(address);
}
if segments[..6].iter().all(|segment| *segment == 0) {
let [a, b] = segments[6].to_be_bytes();
let [c, d] = segments[7].to_be_bytes();
return is_public_ipv4(Ipv4Addr::new(a, b, c, d));
}
!(address.is_unspecified()
|| address.is_loopback()
|| address.is_multicast()
|| (segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80
|| (segments[0] & 0xffc0) == 0xfec0
|| (segments[0] == 0x0064
&& segments[1] == 0xff9b
&& segments[2..6].iter().all(|segment| *segment == 0))
|| (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1)
|| segments[0] == 0x2002
|| (segments[0] == 0x2001 && matches!(segments[1], 0 | 0x0db8)))
} }
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> { fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
@@ -118,6 +475,9 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName { HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName {
header: name.to_owned(), header: name.to_owned(),
})?; })?;
if is_trace_propagation_header(&header_name) {
return Ok(());
}
let header_value = let header_value =
HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue { HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue {
header: name.to_owned(), header: name.to_owned(),
@@ -127,8 +487,61 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
Ok(()) Ok(())
} }
async fn decode_body(response: reqwest::Response) -> Result<Value, RestAdapterError> { fn is_trace_propagation_header(name: &HeaderName) -> bool {
let bytes = response.bytes().await?; 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() { if bytes.is_empty() {
return Ok(Value::Null); return Ok(Value::Null);
+6
View File
@@ -13,6 +13,12 @@ pub enum RestAdapterError {
InvalidHeaderName { header: String }, InvalidHeaderName { header: String },
#[error("invalid header value for {header}")] #[error("invalid header value for {header}")]
InvalidHeaderValue { header: String }, InvalidHeaderValue { header: String },
#[error("outbound target is not allowed: {target}")]
TargetNotAllowed { target: String },
#[error("rest response exceeds the configured limit of {limit_bytes} bytes")]
ResponseTooLarge { limit_bytes: usize },
#[error("invalid outbound HTTP configuration: {details}")]
InvalidConfiguration { details: String },
#[error("request failed")] #[error("request failed")]
Transport(#[from] reqwest::Error), Transport(#[from] reqwest::Error),
#[error("sse collection window expired before stream completed")] #[error("sse collection window expired before stream completed")]
+5 -3
View File
@@ -8,7 +8,7 @@ use crank_core::{
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target, ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
}; };
pub use client::RestAdapter; pub use client::{OutboundHttpPolicy, RestAdapter};
pub use error::RestAdapterError; pub use error::RestAdapterError;
pub use model::{RestRequest, RestResponse}; pub use model::{RestRequest, RestResponse};
@@ -26,13 +26,15 @@ impl ProtocolAdapter for RestAdapter {
&self, &self,
target: &Target, target: &Target,
prepared: &PreparedRequest, prepared: &PreparedRequest,
_context: &RuntimeRequestContext, context: &RuntimeRequestContext,
) -> Result<AdapterResponse, ProtocolAdapterError> { ) -> Result<AdapterResponse, ProtocolAdapterError> {
let target = rest_target(target)?; let target = rest_target(target)?;
let mut headers = prepared.headers.clone();
headers.extend(context.outbound_headers());
let request = RestRequest { let request = RestRequest {
path_params: prepared.path_params.clone(), path_params: prepared.path_params.clone(),
query_params: prepared.query_params.clone(), query_params: prepared.query_params.clone(),
headers: prepared.headers.clone(), headers,
body: prepared.body.clone(), body: prepared.body.clone(),
timeout_ms: prepared.timeout_ms, timeout_ms: prepared.timeout_ms,
}; };
@@ -3,18 +3,29 @@ use std::collections::BTreeMap;
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, Query}, extract::{Path, Query},
http::HeaderMap, http::{HeaderMap, StatusCode},
response::Redirect,
routing::{get, post}, routing::{get, post},
}; };
use crank_adapter_rest::{RestAdapter, RestAdapterError, RestRequest}; use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
use crank_core::{HttpMethod, RestTarget}; use crank_core::{
HttpMethod, PreparedRequest, ProtocolAdapter, RestTarget, RuntimeRequestContext, Target,
};
use opentelemetry::{
global,
trace::{TraceContextExt, TracerProvider as _},
};
use opentelemetry_sdk::{propagation::TraceContextPropagator, trace::SdkTracerProvider};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use tracing_subscriber::layer::SubscriberExt;
#[tokio::test] #[tokio::test]
async fn executes_rest_request_and_normalizes_json_response() { async fn executes_rest_request_and_normalizes_json_response() {
let base_url = spawn_test_server().await; let base_url = spawn_test_server().await;
let adapter = RestAdapter::new(); let adapter = test_adapter();
let target = RestTarget { let target = RestTarget {
base_url, base_url,
method: HttpMethod::Post, method: HttpMethod::Post,
@@ -44,10 +55,124 @@ async fn executes_rest_request_and_normalizes_json_response() {
); );
} }
#[tokio::test]
async fn protocol_context_overrides_mapped_correlation_headers() {
let base_url = spawn_test_server().await;
let adapter = test_adapter();
let target = Target::Rest(RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/users/{user_id}".to_owned(),
static_headers: BTreeMap::from([
("x-request-id".to_owned(), "static-request".to_owned()),
(
"x-correlation-id".to_owned(),
"static-correlation".to_owned(),
),
]),
});
let prepared = PreparedRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
headers: BTreeMap::from([
("x-request-id".to_owned(), "mapped-request".to_owned()),
(
"x-correlation-id".to_owned(),
"mapped-correlation".to_owned(),
),
]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..PreparedRequest::default()
};
let context = RuntimeRequestContext::new("req-runtime", "corr-runtime");
let response = adapter
.invoke_unary(&target, &prepared, &context)
.await
.unwrap();
assert_eq!(response.body["request_id"], "req-runtime");
assert_eq!(response.body["correlation_id"], "corr-runtime");
}
#[tokio::test(flavor = "current_thread")]
async fn current_trace_context_overrides_mapped_traceparent() {
global::set_text_map_propagator(TraceContextPropagator::new());
let provider = SdkTracerProvider::builder().build();
let tracer = provider.tracer("rest-propagation-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 span = tracing::info_span!("runtime.execute");
let context = span.context();
let expected_trace_id = context.span().span_context().trace_id().to_string();
let base_url = spawn_test_server().await;
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/users/{user_id}".to_owned(),
static_headers: BTreeMap::new(),
};
let request = RestRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
query_params: BTreeMap::new(),
headers: BTreeMap::from([(
"traceparent".to_owned(),
"00-11111111111111111111111111111111-2222222222222222-01".to_owned(),
)]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
};
let response = test_adapter()
.execute(&target, &request)
.instrument(span)
.await
.unwrap();
assert_eq!(
&response.body["traceparent"].as_str().unwrap()[3..35],
expected_trace_id
);
provider.shutdown().unwrap();
}
#[tokio::test]
async fn user_configured_propagation_headers_are_removed_without_trusted_context() {
let base_url = spawn_test_server().await;
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/users/{user_id}".to_owned(),
static_headers: BTreeMap::from([
(
"traceparent".to_owned(),
"untrusted\ninvalid-value".to_owned(),
),
("tracestate".to_owned(), "vendor=value".to_owned()),
("baggage".to_owned(), "secret=must-not-leave".to_owned()),
]),
};
let request = RestRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
query_params: BTreeMap::new(),
headers: BTreeMap::new(),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
};
let response = test_adapter().execute(&target, &request).await.unwrap();
assert!(response.body.get("traceparent").is_none());
assert!(response.body.get("tracestate").is_none());
assert!(response.body.get("baggage").is_none());
}
#[tokio::test] #[tokio::test]
async fn returns_unexpected_status_with_normalized_body() { async fn returns_unexpected_status_with_normalized_body() {
let base_url = spawn_test_server().await; let base_url = spawn_test_server().await;
let adapter = RestAdapter::new(); let adapter = test_adapter();
let target = RestTarget { let target = RestTarget {
base_url, base_url,
method: HttpMethod::Get, method: HttpMethod::Get,
@@ -73,10 +198,94 @@ async fn returns_unexpected_status_with_normalized_body() {
)); ));
} }
#[test]
fn rejects_private_targets_by_default() {
let policy = OutboundHttpPolicy::default();
let error = policy
.validate_base_url("http://127.0.0.1:8080")
.unwrap_err();
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
}
#[test]
fn accepts_explicit_private_ipv4_and_ipv6_targets() {
let policy = OutboundHttpPolicy::allowing_hosts(["192.168.1.10", "::1"]);
assert!(policy.validate_base_url("http://192.168.1.10:8080").is_ok());
assert!(policy.validate_base_url("http://[::1]:8080").is_ok());
}
#[tokio::test]
async fn does_not_follow_redirects() {
let base_url = spawn_test_server().await;
let adapter = test_adapter();
let target = RestTarget {
base_url,
method: HttpMethod::Get,
path_template: "/redirect".to_owned(),
static_headers: BTreeMap::new(),
};
let error = adapter
.execute(&target, &empty_request())
.await
.unwrap_err();
assert!(matches!(
error,
RestAdapterError::UnexpectedStatus { status: 303, .. }
));
}
#[tokio::test]
async fn rejects_responses_over_the_configured_limit() {
let base_url = spawn_test_server().await;
let adapter = RestAdapter::with_policy(
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_response_bytes(8),
);
let target = RestTarget {
base_url,
method: HttpMethod::Get,
path_template: "/large".to_owned(),
static_headers: BTreeMap::new(),
};
let error = adapter
.execute(&target, &empty_request())
.await
.unwrap_err();
assert!(matches!(
error,
RestAdapterError::ResponseTooLarge { limit_bytes: 8 }
));
}
fn empty_request() -> RestRequest {
RestRequest {
path_params: BTreeMap::new(),
query_params: BTreeMap::new(),
headers: BTreeMap::new(),
body: None,
timeout_ms: 1_000,
}
}
fn test_adapter() -> RestAdapter {
RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]))
}
async fn spawn_test_server() -> String { async fn spawn_test_server() -> String {
let app = Router::new() let app = Router::new()
.route("/users/{user_id}", post(create_user)) .route("/users/{user_id}", post(create_user))
.route("/fail", get(fail)); .route("/fail", get(fail))
.route("/redirect", get(|| async { Redirect::to("/large") }))
.route(
"/large",
get(|| async { "response larger than eight bytes" }),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap(); let address = listener.local_addr().unwrap();
@@ -101,19 +310,62 @@ async fn create_user(
.get("x-static") .get("x-static")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.unwrap_or_default(); .unwrap_or_default();
let request_id = headers
.get("x-request-id")
.and_then(|value| value.to_str().ok());
let correlation_id = headers
.get("x-correlation-id")
.and_then(|value| value.to_str().ok());
let traceparent = headers
.get("traceparent")
.and_then(|value| value.to_str().ok());
let tracestate = headers
.get("tracestate")
.and_then(|value| value.to_str().ok());
let baggage = headers.get("baggage").and_then(|value| value.to_str().ok());
Json(json!({ let mut response = json!({
"id": user_id, "id": user_id,
"query": query.get("expand").cloned().unwrap_or_default(), "query": query.get("expand").cloned().unwrap_or_default(),
"trace": trace, "trace": trace,
"static": static_header, "static": static_header,
"payload": payload "payload": payload
})) });
let response = response.as_object_mut().unwrap();
if let Some(request_id) = request_id {
response.insert(
"request_id".to_owned(),
Value::String(request_id.to_owned()),
);
}
if let Some(correlation_id) = correlation_id {
response.insert(
"correlation_id".to_owned(),
Value::String(correlation_id.to_owned()),
);
}
if let Some(traceparent) = traceparent {
response.insert(
"traceparent".to_owned(),
Value::String(traceparent.to_owned()),
);
}
if let Some(tracestate) = tracestate {
response.insert(
"tracestate".to_owned(),
Value::String(tracestate.to_owned()),
);
}
if let Some(baggage) = baggage {
response.insert("baggage".to_owned(), Value::String(baggage.to_owned()));
}
Json(Value::Object(response.clone()))
} }
async fn fail() -> (axum::http::StatusCode, Json<Value>) { async fn fail() -> (axum::http::StatusCode, Json<Value>) {
( (
axum::http::StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
Json(json!({ "error": "upstream failed" })), Json(json!({ "error": "upstream failed" })),
) )
} }
+1
View File
@@ -3,6 +3,7 @@ name = "crank-community-auth"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true
version.workspace = true version.workspace = true
[dependencies] [dependencies]
@@ -52,7 +52,11 @@ impl IdentityProvider for PasswordIdentityProvider {
&self.password_pepper, &self.password_pepper,
&user.password_hash, &user.password_hash,
) { ) {
debug!(email = %payload.email, "password identity provider rejected credentials"); debug!(
name: "auth.password.rejected",
identity_provider = "password",
"password identity provider rejected credentials"
);
return Err(IdentityError::BadCredentials); return Err(IdentityError::BadCredentials);
} }
+9
View File
@@ -3,6 +3,7 @@ name = "crank-community-mcp"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true
version.workspace = true version.workspace = true
[dependencies] [dependencies]
@@ -11,10 +12,14 @@ axum.workspace = true
base64.workspace = true base64.workspace = true
crank-adapter-rest = { path = "../crank-adapter-rest" } crank-adapter-rest = { path = "../crank-adapter-rest" }
crank-core = { path = "../crank-core" } crank-core = { path = "../crank-core" }
crank-observability = { path = "../crank-observability" }
crank-registry = { path = "../crank-registry" } crank-registry = { path = "../crank-registry" }
crank-runtime = { path = "../crank-runtime" } crank-runtime = { path = "../crank-runtime" }
crank-schema = { path = "../crank-schema" } crank-schema = { path = "../crank-schema" }
crank-trace = { path = "../crank-trace" }
futures-util = "0.3" futures-util = "0.3"
metrics.workspace = true
reqwest.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
@@ -28,3 +33,7 @@ uuid.workspace = true
[dev-dependencies] [dev-dependencies]
crank-mapping = { path = "../crank-mapping" } crank-mapping = { path = "../crank-mapping" }
crank-test-support = { path = "../crank-test-support" } crank-test-support = { path = "../crank-test-support" }
opentelemetry.workspace = true
opentelemetry_sdk.workspace = true
tracing-opentelemetry.workspace = true
tracing-subscriber.workspace = true
+91 -27
View File
@@ -1,27 +1,54 @@
use std::sync::Arc; use std::sync::Arc;
use axum::http::{HeaderMap, StatusCode, header::AUTHORIZATION}; use axum::{
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
response::{IntoResponse, Response},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{OperationSecurityLevel, PlatformApiKeyScope}; use crank_core::{OperationSecurityLevel, PlatformApiKeyScope};
use crank_trace::{DbOperation, ErrorCategory, StageOutcome, observe_db_query};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::Instrument;
use crate::{ use crate::{
app::{AgentRoutePath, AppState}, app::{AgentRoutePath, AppState},
auth::VerifiedMachineCredential, auth::VerifiedMachineCredential,
}; };
#[derive(Clone, Copy, Debug)]
pub(super) enum MachineAccessError {
Denied(StatusCode),
Unavailable,
}
impl MachineAccessError {
pub(super) fn is_denied(self) -> bool {
matches!(self, Self::Denied(_))
}
}
impl IntoResponse for MachineAccessError {
fn into_response(self) -> Response {
match self {
Self::Denied(status) => status.into_response(),
Self::Unavailable => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
pub(super) async fn require_machine_access( pub(super) async fn require_machine_access(
state: &Arc<AppState>, state: &Arc<AppState>,
path: &AgentRoutePath, path: &AgentRoutePath,
headers: &HeaderMap, headers: &HeaderMap,
required_scope: PlatformApiKeyScope, required_scope: PlatformApiKeyScope,
) -> Result<VerifiedMachineCredential, StatusCode> { ) -> Result<VerifiedMachineCredential, MachineAccessError> {
let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; let secret =
bearer_token(headers).ok_or(MachineAccessError::Denied(StatusCode::UNAUTHORIZED))?;
let credential = resolve_machine_credential(state, path, secret).await?; let credential = resolve_machine_credential(state, path, secret).await?;
if !allows_scope(&credential.scopes, required_scope) { if !allows_scope(&credential.scopes, required_scope) {
return Err(StatusCode::FORBIDDEN); return Err(MachineAccessError::Denied(StatusCode::FORBIDDEN));
} }
Ok(credential) Ok(credential)
@@ -35,15 +62,18 @@ pub(super) async fn require_approval_access(
) -> Result<crank_registry::PlatformApiKeyRecord, StatusCode> { ) -> Result<crank_registry::PlatformApiKeyRecord, StatusCode> {
let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?;
let secret_hash = hash_access_secret(secret); let secret_hash = hash_access_secret(secret);
let Some(api_key) = state let Some(api_key) = observe_db_query(
.registry DbOperation::MachineAccessRead,
.get_approval_api_key_by_secret_for_agent_slug( state
&path.workspace_slug, .registry
&path.agent_slug, .get_approval_api_key_by_secret_for_agent_slug(
&secret_hash, &path.workspace_slug,
) &path.agent_slug,
.await &secret_hash,
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? ),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
else { else {
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
}; };
@@ -53,11 +83,16 @@ pub(super) async fn require_approval_access(
} }
let used_at = OffsetDateTime::now_utc(); let used_at = OffsetDateTime::now_utc();
state observe_db_query(
.registry DbOperation::MachineAccessTouch,
.touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at) state.registry.touch_platform_api_key(
.await &api_key.api_key.workspace_id,
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; &api_key.api_key.id,
&used_at,
),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(api_key) Ok(api_key)
} }
@@ -100,7 +135,7 @@ async fn resolve_machine_credential(
state: &Arc<AppState>, state: &Arc<AppState>,
path: &AgentRoutePath, path: &AgentRoutePath,
token: &str, token: &str,
) -> Result<VerifiedMachineCredential, StatusCode> { ) -> Result<VerifiedMachineCredential, MachineAccessError> {
if let Some(credential) = verify_static_agent_key(state, path, token).await? { if let Some(credential) = verify_static_agent_key(state, path, token).await? {
return Ok(credential); return Ok(credential);
} }
@@ -109,35 +144,64 @@ async fn resolve_machine_credential(
.credential_verifier .credential_verifier
.verify_bearer_token(&path.workspace_slug, &path.agent_slug, token) .verify_bearer_token(&path.workspace_slug, &path.agent_slug, token)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| MachineAccessError::Unavailable)?
.ok_or(StatusCode::UNAUTHORIZED) .ok_or(MachineAccessError::Denied(StatusCode::UNAUTHORIZED))
} }
async fn verify_static_agent_key( async fn verify_static_agent_key(
state: &Arc<AppState>, state: &Arc<AppState>,
path: &AgentRoutePath, path: &AgentRoutePath,
secret: &str, secret: &str,
) -> Result<Option<VerifiedMachineCredential>, StatusCode> { ) -> Result<Option<VerifiedMachineCredential>, MachineAccessError> {
let secret_hash = hash_access_secret(secret); let secret_hash = hash_access_secret(secret);
let Some(api_key) = state let read_span = DbOperation::MachineAccessRead.span();
let api_key_result = state
.registry .registry
.get_platform_api_key_by_secret_for_agent_slug( .get_platform_api_key_by_secret_for_agent_slug(
&path.workspace_slug, &path.workspace_slug,
&path.agent_slug, &path.agent_slug,
&secret_hash, &secret_hash,
) )
.instrument(read_span.clone())
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| MachineAccessError::Unavailable);
else { let api_key = match api_key_result {
Ok(api_key) => {
StageOutcome::Success.record(&read_span);
drop(read_span);
api_key
}
Err(status) => {
StageOutcome::Error.record(&read_span);
ErrorCategory::Database.record(&read_span);
drop(read_span);
return Err(status);
}
};
let Some(api_key) = api_key else {
return Ok(None); return Ok(None);
}; };
let used_at = OffsetDateTime::now_utc(); let used_at = OffsetDateTime::now_utc();
state let touch_span = DbOperation::MachineAccessTouch.span();
let touch_result = state
.registry .registry
.touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at) .touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at)
.instrument(touch_span.clone())
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| MachineAccessError::Unavailable);
match touch_result {
Ok(()) => {
StageOutcome::Success.record(&touch_span);
drop(touch_span);
}
Err(status) => {
StageOutcome::Error.record(&touch_span);
ErrorCategory::Database.record(&touch_span);
drop(touch_span);
return Err(status);
}
}
Ok(Some(VerifiedMachineCredential { Ok(Some(VerifiedMachineCredential {
machine_access_mode: crank_core::MachineAccessMode::StaticAgentKey, machine_access_mode: crank_core::MachineAccessMode::StaticAgentKey,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
use std::{sync::Arc, time::Duration};
use crank_core::{
InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus,
};
use crank_registry::{
CreateInvocationLogRequest, InvocationHistoryWriteOutcome, PublishedAgentTool,
};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome};
use serde_json::Value;
use time::OffsetDateTime;
use tracing::{Instrument, warn};
use super::AppState;
pub(crate) struct InvocationRecord<'a> {
pub(crate) request_id: Option<&'a str>,
pub(crate) tool_name: &'a str,
pub(crate) status: InvocationStatus,
pub(crate) level: InvocationLevel,
pub(crate) message: &'a str,
pub(crate) status_code: Option<u16>,
pub(crate) error_kind: Option<&'a str>,
pub(crate) duration: Duration,
pub(crate) request_preview: Value,
pub(crate) response_preview: Value,
}
pub(crate) async fn persist_invocation(
state: &Arc<AppState>,
tool: &PublishedAgentTool,
record: InvocationRecord<'_>,
) -> InvocationHistoryWriteOutcome {
let log = InvocationLog {
id: InvocationLogId::new(format!("log_{}", uuid::Uuid::now_v7().simple())),
workspace_id: tool.workspace_id.clone(),
agent_id: Some(tool.agent_id.clone()),
operation_id: tool.operation.id.clone(),
source: InvocationSource::AgentToolCall,
level: record.level,
status: record.status,
tool_name: record.tool_name.to_owned(),
message: record.message.to_owned(),
request_id: record.request_id.map(ToOwned::to_owned),
status_code: record.status_code,
duration_ms: u64::try_from(record.duration.as_millis()).unwrap_or(u64::MAX),
error_kind: record.error_kind.map(ToOwned::to_owned),
request_preview: record.request_preview,
response_preview: record.response_preview,
created_at: OffsetDateTime::now_utc(),
};
let history_span = Stage::HistoryWrite.span();
let (outcome, db_span) = async {
let db_span = DbOperation::InvocationHistoryWrite.span();
let outcome = state
.registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.instrument(db_span.clone())
.await;
(outcome, db_span)
}
.instrument(history_span.clone())
.await;
match outcome {
InvocationHistoryWriteOutcome::Recorded => {
StageOutcome::Success.record(&db_span);
StageOutcome::Success.record(&history_span);
}
InvocationHistoryWriteOutcome::Lost(_) => {
StageOutcome::Error.record(&db_span);
ErrorCategory::Database.record(&db_span);
StageOutcome::Error.record(&history_span);
ErrorCategory::History.record(&history_span);
}
}
drop(db_span);
drop(history_span);
observe_invocation_history_outcome(
outcome,
record.request_id,
record.status,
InvocationSource::AgentToolCall,
);
outcome
}
pub(super) fn observe_invocation_history_outcome(
outcome: InvocationHistoryWriteOutcome,
request_id: Option<&str>,
status: InvocationStatus,
source: InvocationSource,
) {
let Some(loss) = outcome.loss() else {
return;
};
crank_observability::record_operational_incident(
crank_observability::OperationalIncident::InvocationHistoryLost,
);
warn!(
name: "mcp.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: InvocationStatus) -> &'static str {
match status {
InvocationStatus::Ok => "ok",
InvocationStatus::Error => "error",
}
}
@@ -0,0 +1,93 @@
use std::sync::Arc;
use axum::http::StatusCode;
use serde_json::Value;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::{
jsonrpc::{is_notification, is_response, method_name},
transport::ResponseMode,
};
pub(super) struct McpRequestMetrics {
method: &'static str,
response_mode: &'static str,
outcome: &'static str,
}
impl McpRequestMetrics {
pub(super) fn new(message: &Value) -> Self {
Self {
method: normalized_mcp_method(message),
response_mode: "unknown",
outcome: "rejected",
}
}
pub(super) fn set_response_mode(&mut self, mode: ResponseMode) -> ResponseMode {
self.response_mode = match mode {
ResponseMode::Json => "json",
ResponseMode::Sse => "sse",
};
mode
}
pub(super) fn complete(&mut self, status: StatusCode) {
self.outcome = match status.as_u16() {
200..=299 => "success",
400..=499 => "client_error",
500..=599 => "server_error",
_ => "other",
};
}
}
impl Drop for McpRequestMetrics {
fn drop(&mut self) {
::metrics::counter!(
"crank_mcp_requests_total",
"method" => self.method,
"response_mode" => self.response_mode,
"outcome" => self.outcome
)
.increment(1);
}
}
pub(super) fn normalized_mcp_method(message: &Value) -> &'static str {
match method_name(message) {
Some("initialize") => "initialize",
Some("notifications/initialized") => "initialized",
Some("ping") => "ping",
Some("tools/list") => "tools_list",
Some("tools/call") => "tools_call",
Some(_) if is_notification(message) => "notification",
Some(_) => "unsupported",
None if is_response(message) => "response",
None => "invalid",
}
}
pub(super) struct ActiveSessionGuard {
_permit: OwnedSemaphorePermit,
}
impl ActiveSessionGuard {
pub(super) fn try_acquire(slots: &Arc<Semaphore>) -> Result<Self, ()> {
let permit = Arc::clone(slots).try_acquire_owned().map_err(|_| {
::metrics::counter!(
"crank_runtime_limit_rejections_total",
"stage" => "mcp_session"
)
.increment(1);
})?;
::metrics::gauge!("crank_mcp_active_sessions").increment(1.0);
Ok(Self { _permit: permit })
}
}
impl Drop for ActiveSessionGuard {
fn drop(&mut self) {
::metrics::gauge!("crank_mcp_active_sessions").decrement(1.0);
}
}
@@ -0,0 +1,86 @@
use std::sync::Arc;
use axum::http::{HeaderMap, StatusCode};
use crank_core::PlatformApiKeyScope;
use crank_registry::PlatformApiKeyRecord;
use crank_runtime::RateLimitCheckError;
use crank_trace::{ErrorCategory, Stage, StageOutcome};
use tracing::Instrument;
use crate::{
access::{MachineAccessError, require_approval_access, require_machine_access},
app::{AgentRoutePath, AppState},
auth::VerifiedMachineCredential,
rate_limit::enforce_transport_rate_limit,
};
pub(super) async fn enforce_traced_rate_limit(
state: &Arc<AppState>,
path: &AgentRoutePath,
headers: &HeaderMap,
) -> Result<(), RateLimitCheckError> {
let span = Stage::McpRateLimit.span();
let result = enforce_transport_rate_limit(state, path, headers)
.instrument(span.clone())
.await;
match &result {
Ok(()) => StageOutcome::Allowed.record(&span),
Err(RateLimitCheckError::Rejected(_)) => {
StageOutcome::Denied.record(&span);
ErrorCategory::RateLimit.record(&span);
}
Err(RateLimitCheckError::StoreUnavailable) => {
StageOutcome::Error.record(&span);
ErrorCategory::Internal.record(&span);
}
}
result
}
pub(super) async fn require_traced_machine_access(
state: &Arc<AppState>,
path: &AgentRoutePath,
headers: &HeaderMap,
required_scope: PlatformApiKeyScope,
) -> Result<VerifiedMachineCredential, MachineAccessError> {
let span = Stage::McpAccessCheck.span();
let result = require_machine_access(state, path, headers, required_scope)
.instrument(span.clone())
.await;
match &result {
Ok(_) => StageOutcome::Allowed.record(&span),
Err(error) if error.is_denied() => {
StageOutcome::Denied.record(&span);
ErrorCategory::Access.record(&span);
}
Err(_) => {
StageOutcome::Error.record(&span);
ErrorCategory::Internal.record(&span);
}
}
result
}
pub(super) async fn require_traced_approval_access(
state: &Arc<AppState>,
path: &AgentRoutePath,
headers: &HeaderMap,
required_scope: PlatformApiKeyScope,
) -> Result<PlatformApiKeyRecord, StatusCode> {
let span = Stage::McpAccessCheck.span();
let result = require_approval_access(state, path, headers, required_scope)
.instrument(span.clone())
.await;
match &result {
Ok(_) => StageOutcome::Allowed.record(&span),
Err(status) if *status == StatusCode::UNAUTHORIZED || *status == StatusCode::FORBIDDEN => {
StageOutcome::Denied.record(&span);
ErrorCategory::Access.record(&span);
}
Err(_) => {
StageOutcome::Error.record(&span);
ErrorCategory::Internal.record(&span);
}
}
result
}

Some files were not shown because too many files have changed in this diff Show More