From 231ec47c4f3212f2a178ee64fe03a74b1f2504a0 Mon Sep 17 00:00:00 2001 From: Mike Ryan Date: Mon, 27 Jul 2026 10:50:34 -0700 Subject: [PATCH] feat(integrations): align managed Intelligence starters --- .github/workflows/integrations_parity.yml | 5 + examples/integrations/agentcore/.env.example | 15 + examples/integrations/agentcore/README.md | 54 +- .../agentcore/config.yaml.example | 2 + .../agentcore/deploy-langgraph.sh | 81 +- .../integrations/agentcore/deploy-strands.sh | 81 +- .../agentcore/docker/.env.example | 7 - .../agentcore/docker/docker-compose.yml | 9 +- .../src/components/chat/CopilotKit/index.tsx | 15 +- .../agentcore/infra-cdk/jest.config.js | 6 + .../copilotkit-runtime/src/identity.ts | 89 + .../lambdas/copilotkit-runtime/src/index.ts | 8 +- .../lambdas/copilotkit-runtime/src/runtime.ts | 73 +- .../lambdas/copilotkit-runtime/src/server.ts | 2 +- .../infra-cdk/lib/amplify-hosting-stack.ts | 4 +- .../agentcore/infra-cdk/lib/backend-stack.ts | 92 +- .../infra-cdk/lib/copilotkit-runtime-auth.ts | 38 + .../infra-cdk/lib/utils/config-manager.ts | 8 + .../test/copilotkit-runtime-auth.test.ts | 124 + .../agentcore/infra-terraform/README.md | 5 +- .../agentcore-deploy-skip-backend.test.ts | 87 + .../agentcore-runtime-security.test.ts | 216 + ...integration-intelligence-migration.test.ts | 4730 +++++++++++++++-- 23 files changed, 5213 insertions(+), 538 deletions(-) create mode 100644 examples/integrations/agentcore/.env.example create mode 100644 examples/integrations/agentcore/infra-cdk/jest.config.js create mode 100644 examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/identity.ts create mode 100644 examples/integrations/agentcore/infra-cdk/lib/copilotkit-runtime-auth.ts create mode 100644 examples/integrations/agentcore/infra-cdk/test/copilotkit-runtime-auth.test.ts create mode 100644 scripts/__tests__/agentcore-deploy-skip-backend.test.ts create mode 100644 scripts/__tests__/agentcore-runtime-security.test.ts diff --git a/.github/workflows/integrations_parity.yml b/.github/workflows/integrations_parity.yml index f3914eefda..10a9e80b2f 100644 --- a/.github/workflows/integrations_parity.yml +++ b/.github/workflows/integrations_parity.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - "examples/integrations/**" + - "scripts/__tests__/integration-intelligence-migration.test.ts" - ".github/workflows/integrations_parity.yml" push: branches: [main] paths: - "examples/integrations/**" + - "scripts/__tests__/integration-intelligence-migration.test.ts" - ".github/workflows/integrations_parity.yml" permissions: @@ -48,3 +50,6 @@ jobs: echo "If this fails, run: pnpm parity:sync --target=" echo "and resolve agent-surface drift manually (see _parity/README.md)." pnpm parity:check + + - name: Verify Intelligence template credential contracts + run: pnpm exec vitest run scripts/__tests__/integration-intelligence-migration.test.ts diff --git a/examples/integrations/agentcore/.env.example b/examples/integrations/agentcore/.env.example new file mode 100644 index 0000000000..a7c2e2ca08 --- /dev/null +++ b/examples/integrations/agentcore/.env.example @@ -0,0 +1,15 @@ +# Your CopilotKit Enterprise Intelligence API Key +# Project Name: agentcore +CPK_INTELLIGENCE_API_KEY= + +# CopilotKit Telemetry ID +# Optional non-secret analytics identity. +CPK_TELEMETRY_ID= + +# Pinned SDK compatibility / offline license token +# Required by the pinned SDK for Threads entitlement checks. +COPILOTKIT_LICENSE_TOKEN= + +# Docker Compose maps this name to the host running local Intelligence. +INTELLIGENCE_API_URL=http://host.docker.internal:4201 +INTELLIGENCE_GATEWAY_WS_URL=ws://host.docker.internal:4401 diff --git a/examples/integrations/agentcore/README.md b/examples/integrations/agentcore/README.md index b28bdd240a..7104c500d0 100644 --- a/examples/integrations/agentcore/README.md +++ b/examples/integrations/agentcore/README.md @@ -14,35 +14,62 @@ Chat UI with generative charts, shared-state todo canvas, and inline tool render The Python side is managed entirely by uv — it provisions the interpreter, so there is no separate Python install step. +## Managed Intelligence credentials + +Create the root environment file before deploying or running locally: + +```bash +cp .env.example .env +``` + +Set `CPK_INTELLIGENCE_API_KEY` to the API key for your managed CopilotKit +Intelligence project. `CPK_TELEMETRY_ID` is an optional, non-secret analytics +identity and can stay blank. The pinned SDK token setup is below. + ## Deploy to AWS -1. **Create your config:** +1. **Create your environment and config:** ```bash + cp .env.example .env cp config.yaml.example config.yaml - # Edit config.yaml — set stack_name_base and admin_user_email + # Edit .env and config.yaml. ``` + Set `stack_name_base` and `admin_user_email` in `config.yaml`. The deploy + script stores the managed key and compatibility token from `.env` in their + configured AWS Secrets Manager secrets. CDK resolves both only for the + CopilotKit runtime Lambda. + + Before deploying, provide managed or self-hosted Intelligence endpoints that are reachable from AWS. AWS deployments must not use `localhost` or `127.0.0.1`; they also must not use the Docker-only `host.docker.internal` name from `.env.example`. + 2. **Deploy:** ```bash + INTELLIGENCE_API_URL=https://intelligence.example.com \ + INTELLIGENCE_GATEWAY_WS_URL=wss://gateway.example.com \ ./deploy-langgraph.sh # LangGraph agent (infra + frontend) ./deploy-langgraph.sh --skip-frontend # infra/agent only ./deploy-langgraph.sh --skip-backend # frontend only # or + INTELLIGENCE_API_URL=https://intelligence.example.com \ + INTELLIGENCE_GATEWAY_WS_URL=wss://gateway.example.com \ ./deploy-strands.sh # AWS Strands agent ./deploy-strands.sh --skip-frontend ./deploy-strands.sh --skip-backend ``` + The command-prefixed endpoint values override the local defaults sourced from `.env`. Use the same prefix with `--skip-frontend` or `--skip-backend` when needed. + 3. **Open** the Amplify URL printed at the end. Sign in with your email. ## Local Development ```bash -cd docker cp .env.example .env -# Fill in AWS creds — STACK_NAME, MEMORY_ID, and aws-exports.json are auto-resolved +cp docker/.env.example docker/.env +cd docker +# Fill in docker/.env AWS creds — STACK_NAME, MEMORY_ID, and aws-exports.json are auto-resolved ./up.sh --build ``` @@ -79,7 +106,7 @@ hashes both, so a dependency change retriggers the image build on the next apply | `agents/strands-single-agent/` | Strands agent with tools + shared todo state | | `pyproject.toml` / `uv.lock` | Dependencies for the `scripts/` helpers | | `infra-cdk/` | CDK: Cognito, AgentCore, CopilotKit Lambda bridge, Amplify | -| `infra-terraform/` | Terraform equivalent — see `infra-terraform/README.md` | +| `infra-terraform/` | Base AgentCore infrastructure without managed Intelligence | | `docker/` | Local dev via Docker Compose | ## Architecture @@ -96,6 +123,23 @@ Browser → API Gateway → CopilotKit Lambda (Node.js, AG-UI bridge) Auth: Cognito OIDC → Bearer token forwarded from browser through Lambda to AgentCore. +## Pinned SDK compatibility and offline licensing + +This template pins `@copilotkit/runtime` and `@copilotkit/react-core` at +`1.62.2`. Those packages predate managed entitlement responses. Until the +pins move to a release with that contract, set `COPILOTKIT_LICENSE_TOKEN` in +`.env` alongside `CPK_INTELLIGENCE_API_KEY`. The token supplies the legacy +Threads entitlement check; it does not replace the managed API key. + +The managed project setup does not issue this compatibility token, so this +pinned template does not expose a key-only managed Threads drawer. Use an +existing self-hosted or offline token-backed setup, or update the two SDK pins +after a release includes structured managed entitlements. + +`CPK_TELEMETRY_ID` stays an optional, separate analytics identity. Offline or +self-hosted deployments can also use `COPILOTKIT_LICENSE_TOKEN` as described +in the self-hosting guide. + ## Tear down ```bash diff --git a/examples/integrations/agentcore/config.yaml.example b/examples/integrations/agentcore/config.yaml.example index bfcf6051da..4ffa91e140 100644 --- a/examples/integrations/agentcore/config.yaml.example +++ b/examples/integrations/agentcore/config.yaml.example @@ -1,6 +1,8 @@ # ── User-editable settings ────────────────────────────────────────────────── stack_name_base: my-copilotkit-agentcore-lg # max 35 chars; used as prefix for all AWS resources admin_user_email: # e.g. you@example.com — auto-creates a Cognito user +copilotkit_intelligence_api_key_secret_name: copilotkit/intelligence/api-key +copilotkit_license_token_secret_name: copilotkit/intelligence/license-token backend: # Set automatically by deploy scripts — do not edit. diff --git a/examples/integrations/agentcore/deploy-langgraph.sh b/examples/integrations/agentcore/deploy-langgraph.sh index 5117a1e0d9..920a5c0521 100755 --- a/examples/integrations/agentcore/deploy-langgraph.sh +++ b/examples/integrations/agentcore/deploy-langgraph.sh @@ -4,6 +4,10 @@ # Stack: -lg (isolated from deploy-strands.sh) # Using Terraform instead? See infra-terraform/README.md set -euo pipefail +set +a +set +x +export -n CPK_INTELLIGENCE_API_KEY +export -n COPILOTKIT_LICENSE_TOKEN SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PATTERN="langgraph-single-agent" @@ -18,16 +22,64 @@ for arg in "$@"; do [[ "$arg" == "--skip-backend" ]] && SKIP_BACKEND=true done +if [ "$SKIP_BACKEND" = false ]; then + if [ ! -f "$SCRIPT_DIR/.env" ]; then + echo "ERROR: $SCRIPT_DIR/.env is required. Copy .env.example and add your managed project credentials." + exit 1 + fi + + INTELLIGENCE_API_URL_OVERRIDE_SET=false + if [ "${INTELLIGENCE_API_URL+x}" = x ]; then + INTELLIGENCE_API_URL_OVERRIDE="$INTELLIGENCE_API_URL" + INTELLIGENCE_API_URL_OVERRIDE_SET=true + fi + INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE_SET=false + if [ "${INTELLIGENCE_GATEWAY_WS_URL+x}" = x ]; then + INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE="$INTELLIGENCE_GATEWAY_WS_URL" + INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE_SET=true + fi + + source "$SCRIPT_DIR/.env" + set +a + set +x + if [ "$INTELLIGENCE_API_URL_OVERRIDE_SET" = true ]; then + export INTELLIGENCE_API_URL="$INTELLIGENCE_API_URL_OVERRIDE" + fi + if [ "$INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE_SET" = true ]; then + export INTELLIGENCE_GATEWAY_WS_URL="$INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE" + fi + : "${CPK_INTELLIGENCE_API_KEY:?CPK_INTELLIGENCE_API_KEY is required in .env}" + : "${COPILOTKIT_LICENSE_TOKEN:?COPILOTKIT_LICENSE_TOKEN is required by the pinned SDK in .env}" + export -n CPK_INTELLIGENCE_API_KEY + export -n COPILOTKIT_LICENSE_TOKEN + export INTELLIGENCE_API_URL="${INTELLIGENCE_API_URL:-}" + export INTELLIGENCE_GATEWAY_WS_URL="${INTELLIGENCE_GATEWAY_WS_URL:-}" +fi +export CPK_TELEMETRY_ID="${CPK_TELEMETRY_ID:-}" + echo "── CopilotKit + AWS AgentCore (LangGraph) ──────────────────────────────" # ── Preflight checks ────────────────────────────────────────────────────────── check_command() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 is required but not installed."; exit 1; } } +require_remote_endpoint() { + local name="$1" + local value="$2" + local example="$3" + if [[ -z "$value" || "$value" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*://(localhost|127\.0\.0\.1|host\.docker\.internal)([:/]|$) ]]; then + echo "ERROR: $name must be a non-local endpoint reachable from AWS (for example, $example). Set it in .env or prefix the deploy command." + exit 1 + fi +} check_command aws -check_command node check_command uv -check_command docker +if [ "$SKIP_BACKEND" = false ]; then + require_remote_endpoint INTELLIGENCE_API_URL "${INTELLIGENCE_API_URL:-}" "https://intelligence.example.com" + require_remote_endpoint INTELLIGENCE_GATEWAY_WS_URL "${INTELLIGENCE_GATEWAY_WS_URL:-}" "wss://gateway.example.com" + check_command node + check_command docker +fi aws sts get-caller-identity --query "Account" --output text >/dev/null 2>&1 || \ { echo "ERROR: AWS credentials not configured. Run: aws configure"; exit 1; } @@ -56,8 +108,33 @@ PYEOF # ── CDK deploy ─────────────────────────────────────────────────────────────── if [ "$SKIP_BACKEND" = true ]; then + unset CPK_INTELLIGENCE_API_KEY + unset COPILOTKIT_LICENSE_TOKEN echo "⚡ Skipping backend deploy (--skip-backend)" else + # Materialize backend credentials only while backend resources are deployed. + CPK_INTELLIGENCE_API_KEY_SECRET_NAME=$(uv run --project "$SCRIPT_DIR" python -c "import re; c=open('$CONFIG').read(); print(re.search(r'^copilotkit_intelligence_api_key_secret_name:\s*([^#\s]+)', c, re.MULTILINE).group(1))") + if aws secretsmanager describe-secret --secret-id "$CPK_INTELLIGENCE_API_KEY_SECRET_NAME" >/dev/null 2>&1; then + CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID=$(printf '%s' "$CPK_INTELLIGENCE_API_KEY" | aws secretsmanager put-secret-value --secret-id "$CPK_INTELLIGENCE_API_KEY_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + else + CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID=$(printf '%s' "$CPK_INTELLIGENCE_API_KEY" | aws secretsmanager create-secret --name "$CPK_INTELLIGENCE_API_KEY_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + fi + unset CPK_INTELLIGENCE_API_KEY + + COPILOTKIT_LICENSE_TOKEN_SECRET_NAME=$(uv run --project "$SCRIPT_DIR" python -c "import re; c=open('$CONFIG').read(); print(re.search(r'^copilotkit_license_token_secret_name:\s*([^#\s]+)', c, re.MULTILINE).group(1))") + if aws secretsmanager describe-secret --secret-id "$COPILOTKIT_LICENSE_TOKEN_SECRET_NAME" >/dev/null 2>&1; then + COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID=$(printf '%s' "$COPILOTKIT_LICENSE_TOKEN" | aws secretsmanager put-secret-value --secret-id "$COPILOTKIT_LICENSE_TOKEN_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + else + COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID=$(printf '%s' "$COPILOTKIT_LICENSE_TOKEN" | aws secretsmanager create-secret --name "$COPILOTKIT_LICENSE_TOKEN_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + fi + unset COPILOTKIT_LICENSE_TOKEN + + : "${CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID:?Secrets Manager did not return a managed key version ID}" + : "${COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID:?Secrets Manager did not return a license token version ID}" + export CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID + export COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID + echo "✓ Managed Intelligence credentials stored in Secrets Manager" + echo "Deploying infrastructure (this takes ~10–15 min on first run)..." cd "$CDK_DIR" npm install --silent diff --git a/examples/integrations/agentcore/deploy-strands.sh b/examples/integrations/agentcore/deploy-strands.sh index 1c77b8c979..327772a017 100755 --- a/examples/integrations/agentcore/deploy-strands.sh +++ b/examples/integrations/agentcore/deploy-strands.sh @@ -4,6 +4,10 @@ # Stack: -st (isolated from deploy-langgraph.sh) # Using Terraform instead? See infra-terraform/README.md set -euo pipefail +set +a +set +x +export -n CPK_INTELLIGENCE_API_KEY +export -n COPILOTKIT_LICENSE_TOKEN SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PATTERN="strands-single-agent" @@ -18,16 +22,64 @@ for arg in "$@"; do [[ "$arg" == "--skip-backend" ]] && SKIP_BACKEND=true done +if [ "$SKIP_BACKEND" = false ]; then + if [ ! -f "$SCRIPT_DIR/.env" ]; then + echo "ERROR: $SCRIPT_DIR/.env is required. Copy .env.example and add your managed project credentials." + exit 1 + fi + + INTELLIGENCE_API_URL_OVERRIDE_SET=false + if [ "${INTELLIGENCE_API_URL+x}" = x ]; then + INTELLIGENCE_API_URL_OVERRIDE="$INTELLIGENCE_API_URL" + INTELLIGENCE_API_URL_OVERRIDE_SET=true + fi + INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE_SET=false + if [ "${INTELLIGENCE_GATEWAY_WS_URL+x}" = x ]; then + INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE="$INTELLIGENCE_GATEWAY_WS_URL" + INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE_SET=true + fi + + source "$SCRIPT_DIR/.env" + set +a + set +x + if [ "$INTELLIGENCE_API_URL_OVERRIDE_SET" = true ]; then + export INTELLIGENCE_API_URL="$INTELLIGENCE_API_URL_OVERRIDE" + fi + if [ "$INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE_SET" = true ]; then + export INTELLIGENCE_GATEWAY_WS_URL="$INTELLIGENCE_GATEWAY_WS_URL_OVERRIDE" + fi + : "${CPK_INTELLIGENCE_API_KEY:?CPK_INTELLIGENCE_API_KEY is required in .env}" + : "${COPILOTKIT_LICENSE_TOKEN:?COPILOTKIT_LICENSE_TOKEN is required by the pinned SDK in .env}" + export -n CPK_INTELLIGENCE_API_KEY + export -n COPILOTKIT_LICENSE_TOKEN + export INTELLIGENCE_API_URL="${INTELLIGENCE_API_URL:-}" + export INTELLIGENCE_GATEWAY_WS_URL="${INTELLIGENCE_GATEWAY_WS_URL:-}" +fi +export CPK_TELEMETRY_ID="${CPK_TELEMETRY_ID:-}" + echo "── CopilotKit + AWS AgentCore (Strands) ────────────────────────────────" # ── Preflight checks ────────────────────────────────────────────────────────── check_command() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 is required but not installed."; exit 1; } } +require_remote_endpoint() { + local name="$1" + local value="$2" + local example="$3" + if [[ -z "$value" || "$value" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*://(localhost|127\.0\.0\.1|host\.docker\.internal)([:/]|$) ]]; then + echo "ERROR: $name must be a non-local endpoint reachable from AWS (for example, $example). Set it in .env or prefix the deploy command." + exit 1 + fi +} check_command aws -check_command node check_command uv -check_command docker +if [ "$SKIP_BACKEND" = false ]; then + require_remote_endpoint INTELLIGENCE_API_URL "${INTELLIGENCE_API_URL:-}" "https://intelligence.example.com" + require_remote_endpoint INTELLIGENCE_GATEWAY_WS_URL "${INTELLIGENCE_GATEWAY_WS_URL:-}" "wss://gateway.example.com" + check_command node + check_command docker +fi aws sts get-caller-identity --query "Account" --output text >/dev/null 2>&1 || \ { echo "ERROR: AWS credentials not configured. Run: aws configure"; exit 1; } @@ -55,8 +107,33 @@ PYEOF # ── CDK deploy ─────────────────────────────────────────────────────────────── if [ "$SKIP_BACKEND" = true ]; then + unset CPK_INTELLIGENCE_API_KEY + unset COPILOTKIT_LICENSE_TOKEN echo "⚡ Skipping backend deploy (--skip-backend)" else + # Materialize backend credentials only while backend resources are deployed. + CPK_INTELLIGENCE_API_KEY_SECRET_NAME=$(uv run --project "$SCRIPT_DIR" python -c "import re; c=open('$CONFIG').read(); print(re.search(r'^copilotkit_intelligence_api_key_secret_name:\s*([^#\s]+)', c, re.MULTILINE).group(1))") + if aws secretsmanager describe-secret --secret-id "$CPK_INTELLIGENCE_API_KEY_SECRET_NAME" >/dev/null 2>&1; then + CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID=$(printf '%s' "$CPK_INTELLIGENCE_API_KEY" | aws secretsmanager put-secret-value --secret-id "$CPK_INTELLIGENCE_API_KEY_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + else + CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID=$(printf '%s' "$CPK_INTELLIGENCE_API_KEY" | aws secretsmanager create-secret --name "$CPK_INTELLIGENCE_API_KEY_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + fi + unset CPK_INTELLIGENCE_API_KEY + + COPILOTKIT_LICENSE_TOKEN_SECRET_NAME=$(uv run --project "$SCRIPT_DIR" python -c "import re; c=open('$CONFIG').read(); print(re.search(r'^copilotkit_license_token_secret_name:\s*([^#\s]+)', c, re.MULTILINE).group(1))") + if aws secretsmanager describe-secret --secret-id "$COPILOTKIT_LICENSE_TOKEN_SECRET_NAME" >/dev/null 2>&1; then + COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID=$(printf '%s' "$COPILOTKIT_LICENSE_TOKEN" | aws secretsmanager put-secret-value --secret-id "$COPILOTKIT_LICENSE_TOKEN_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + else + COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID=$(printf '%s' "$COPILOTKIT_LICENSE_TOKEN" | aws secretsmanager create-secret --name "$COPILOTKIT_LICENSE_TOKEN_SECRET_NAME" --secret-string file:///dev/stdin --query VersionId --output text) + fi + unset COPILOTKIT_LICENSE_TOKEN + + : "${CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID:?Secrets Manager did not return a managed key version ID}" + : "${COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID:?Secrets Manager did not return a license token version ID}" + export CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID + export COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID + echo "✓ Managed Intelligence credentials stored in Secrets Manager" + echo "Deploying infrastructure (this takes ~10–15 min on first run)..." cd "$CDK_DIR" npm install --silent diff --git a/examples/integrations/agentcore/docker/.env.example b/examples/integrations/agentcore/docker/.env.example index 92deb1a0a3..a572a013d2 100644 --- a/examples/integrations/agentcore/docker/.env.example +++ b/examples/integrations/agentcore/docker/.env.example @@ -27,10 +27,3 @@ AWS_DEFAULT_REGION=us-east-1 # delete or blank the line and ./up.sh and docker-compose.yml both fall back to # langgraph. AGENT=strands - -# ── CopilotKit Intelligence / Threads (optional) ────────────────────────────── -# Enables persistent Threads in the CopilotKit bridge + frontend. -COPILOTKIT_LICENSE_TOKEN= -INTELLIGENCE_API_KEY= -INTELLIGENCE_API_URL=http://localhost:4201 -INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401 diff --git a/examples/integrations/agentcore/docker/docker-compose.yml b/examples/integrations/agentcore/docker/docker-compose.yml index c70f3023a5..0bee47e83e 100644 --- a/examples/integrations/agentcore/docker/docker-compose.yml +++ b/examples/integrations/agentcore/docker/docker-compose.yml @@ -63,17 +63,16 @@ services: dockerfile: ../../../docker/Dockerfile.bridge.dev ports: - "3001:3001" + env_file: ../.env environment: - AGENTCORE_AG_UI_URL=http://agent:8080/invocations - PORT=3001 - OTEL_SDK_DISABLED=true - - COPILOTKIT_LICENSE_TOKEN=${COPILOTKIT_LICENSE_TOKEN} - - INTELLIGENCE_API_KEY=${INTELLIGENCE_API_KEY} - - INTELLIGENCE_API_URL=${INTELLIGENCE_API_URL:-http://localhost:4201} - - INTELLIGENCE_GATEWAY_WS_URL=${INTELLIGENCE_GATEWAY_WS_URL:-ws://localhost:4401} depends_on: agent: condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" networks: - agentcore-network @@ -86,9 +85,9 @@ services: volumes: - ../frontend:/app - /app/node_modules + env_file: ../.env environment: - NODE_ENV=development - - VITE_COPILOTKIT_THREADS_ENABLED=${COPILOTKIT_LICENSE_TOKEN:+true} depends_on: bridge: condition: service_started diff --git a/examples/integrations/agentcore/frontend/src/components/chat/CopilotKit/index.tsx b/examples/integrations/agentcore/frontend/src/components/chat/CopilotKit/index.tsx index 73f3595239..8ff7063b27 100644 --- a/examples/integrations/agentcore/frontend/src/components/chat/CopilotKit/index.tsx +++ b/examples/integrations/agentcore/frontend/src/components/chat/CopilotKit/index.tsx @@ -61,7 +61,7 @@ function CopilotChatContent() { */
- {/* SDK threads drawer (replaces the hand-rolled fork). License-gated: the locked view's Upgrade CTA opens the Intelligence docs by default. */} + {/* The pinned SDK exposes this drawer only with its compatibility token. */}
@@ -101,15 +101,14 @@ function CopilotChatContent() { function CopilotKitShell({ config, - accessToken, + idToken, }: { config: ResolvedAwsExportsConfig; - accessToken: string | undefined; + idToken: string | undefined; }) { const headers = useMemo( - () => - accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, - [accessToken], + () => (idToken ? { Authorization: `Bearer ${idToken}` } : undefined), + [idToken], ); return ( @@ -170,14 +169,14 @@ export default function CopilotChatInterface() { ); } - const accessToken = auth.user?.access_token ?? auth.user?.id_token; + const idToken = auth.user?.id_token; return (
diff --git a/examples/integrations/agentcore/infra-cdk/jest.config.js b/examples/integrations/agentcore/infra-cdk/jest.config.js new file mode 100644 index 0000000000..6e2d60caca --- /dev/null +++ b/examples/integrations/agentcore/infra-cdk/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["/test/**/*.test.ts"], +}; diff --git a/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/identity.ts b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/identity.ts new file mode 100644 index 0000000000..d942388d12 --- /dev/null +++ b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/identity.ts @@ -0,0 +1,89 @@ +export const VERIFIED_RUNTIME_USER_HEADER = + "x-copilotkit-verified-user-id" as const; + +export interface RuntimeUserIdentity { + readonly id: string; + readonly name: string; +} + +export interface ApiGatewayRuntimeEvent { + readonly headers?: Readonly> | null; + readonly multiValueHeaders?: Readonly< + Record + > | null; + readonly requestContext?: { + readonly authorizer?: { + readonly claims?: Readonly> | null; + } | null; + } | null; + readonly [key: string]: unknown; +} + +export type RuntimeEventHandler = ( + event: ApiGatewayRuntimeEvent, + ...args: unknown[] +) => unknown; + +/** + * Replace any caller-supplied private header with the Cognito claim that API + * Gateway verified. A missing claim removes the header so the Runtime denies it. + */ +export function withVerifiedRuntimeUserHeader( + event: ApiGatewayRuntimeEvent, +): ApiGatewayRuntimeEvent { + const headers = Object.fromEntries( + Object.entries(event.headers ?? {}).filter( + ([name]) => name.toLowerCase() !== VERIFIED_RUNTIME_USER_HEADER, + ), + ); + const multiValueHeaders = Object.fromEntries( + Object.entries(event.multiValueHeaders ?? {}).filter( + ([name]) => name.toLowerCase() !== VERIFIED_RUNTIME_USER_HEADER, + ), + ); + const subject = event.requestContext?.authorizer?.claims?.sub; + if (typeof subject === "string" && subject.trim()) { + headers[VERIFIED_RUNTIME_USER_HEADER] = subject.trim(); + } + + return { + ...event, + headers, + ...(event.multiValueHeaders ? { multiValueHeaders } : {}), + }; +} + +/** + * Wrap a Runtime event handler so Hono only receives the verified Cognito + * subject and never a caller-controlled private identity header. + */ +export function createVerifiedRuntimeHandler( + handler: RuntimeEventHandler, +): RuntimeEventHandler { + return (event, ...args) => + handler(withVerifiedRuntimeUserHeader(event), ...args); +} + +/** + * Resolve the Cognito subject that API Gateway mapped onto its private header. + * The deployed runtime has no anonymous or shared-user fallback. + */ +export function resolveVerifiedRuntimeUser( + request: Request, +): RuntimeUserIdentity { + const id = request.headers.get(VERIFIED_RUNTIME_USER_HEADER)?.trim(); + if (!id) { + throw new Error("Verified Runtime user identity is required"); + } + + return { id, name: id }; +} + +/** Resolve a verified user, with an explicit fallback for the local server only. */ +export function resolveLocalRuntimeUser(request: Request): RuntimeUserIdentity { + try { + return resolveVerifiedRuntimeUser(request); + } catch { + return { id: "local-demo-user", name: "Local Demo User" }; + } +} diff --git a/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/index.ts b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/index.ts index 7ed8fc1380..3ab493c471 100644 --- a/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/index.ts +++ b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/index.ts @@ -1,8 +1,10 @@ import { streamHandle } from "hono/aws-lambda"; import { buildApp } from "./runtime"; +import { createVerifiedRuntimeHandler } from "./identity.js"; +import type { RuntimeEventHandler } from "./identity.js"; const app = buildApp(); +const honoHandler = streamHandle(app) as RuntimeEventHandler; -export const handler: (...args: unknown[]) => unknown = streamHandle(app) as ( - ...args: unknown[] -) => unknown; +/** Inject the verified Cognito subject before Hono converts the event to Request. */ +export const handler = createVerifiedRuntimeHandler(honoHandler); diff --git a/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts index e887d9b51e..f16ca4d380 100644 --- a/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts +++ b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts @@ -13,6 +13,10 @@ import { } from "@copilotkit/runtime/v2"; import { concatMap, of } from "rxjs"; import { randomUUID } from "node:crypto"; +import { + resolveLocalRuntimeUser, + resolveVerifiedRuntimeUser, +} from "./identity.js"; function requireEnv(name: string): string { const value = process.env[name]; @@ -38,21 +42,7 @@ export function buildAgents(): Record { return { [agentName]: agent }; } -/** - * AgentCore stores conversation history in its own memory layer (AgentCoreMemorySaver / - * AgentCoreMemorySessionManager). When CopilotKit reconnects to an existing thread - * (e.g. page refresh), it calls `connect()` which replays that stored history as a - * MESSAGES_SNAPSHOT event. Two issues arise from this that this runner fixes: - * - * 1. Unknown threads — CopilotKit may call `connect()` for a thread it has never - * `run()` against (e.g. on first load). The base runner would error; instead we - * return an empty snapshot so the UI initialises cleanly. - * - * 2. Missing tool-call results — AgentCore's snapshot includes assistant messages - * with tool calls, but the corresponding TOOL_CALL_RESULT events are absent. - * CopilotKit needs those results to reconcile its internal message state. We - * synthesise empty results for every past tool call before emitting the snapshot. - */ +/** Preserves AgentCore snapshot replay behavior for local and managed threads. */ export class AgentCoreRunner extends InMemoryAgentRunner { private readonly knownThreadIds = new Set(); @@ -67,7 +57,6 @@ export class AgentCoreRunner extends InMemoryAgentRunner { request: Parameters[0], ): ReturnType { if (!request.threadId || !this.knownThreadIds.has(request.threadId)) { - // Unknown thread — return an empty snapshot instead of erroring. const runId = typeof (request as { runId?: unknown }).runId === "string" ? ((request as { runId?: string }).runId ?? randomUUID()) @@ -88,8 +77,6 @@ export class AgentCoreRunner extends InMemoryAgentRunner { ) as unknown as ReturnType; } - // Known thread — replay synthetic tool-call results before the snapshot so - // CopilotKit can reconcile its message state correctly. return (super.connect(request) as any).pipe( concatMap((event: any) => { if ( @@ -117,7 +104,9 @@ export class AgentCoreRunner extends InMemoryAgentRunner { } } -export function buildApp() { +export function buildApp( + options: { readonly localDevelopment?: boolean } = {}, +) { const agents = buildAgents(); const agentName = process.env.COPILOTKIT_AGENT_NAME ?? "default"; const defaultAgent = @@ -126,30 +115,28 @@ export function buildApp() { if (!defaultAgent) throw new Error("At least one CopilotKit agent URL must be configured"); - const runtime = new CopilotRuntime({ - agents: { ...agents, default: defaultAgent }, - // --- copilotkit:intelligence (remove this block to opt out) --- - ...(process.env.COPILOTKIT_LICENSE_TOKEN - ? { - intelligence: new CopilotKitIntelligence({ - apiKey: process.env.INTELLIGENCE_API_KEY ?? "", - ...(process.env.INTELLIGENCE_API_URL - ? { apiUrl: process.env.INTELLIGENCE_API_URL } - : {}), - ...(process.env.INTELLIGENCE_GATEWAY_WS_URL - ? { wsUrl: process.env.INTELLIGENCE_GATEWAY_WS_URL } - : {}), - }), - // Demo stub — replace with your real auth-derived user identity before any - // multi-user deployment, or all users share one thread history. The id - // must correspond to a user that exists in CopilotKit Intelligence; - // an unknown id (like this literal) can make thread operations fail. - identifyUser: () => ({ id: "demo-user", name: "Demo User" }), - licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN, - } - : { runner: new AgentCoreRunner() }), - // --- /copilotkit:intelligence --- - }); + const runtime = process.env.CPK_INTELLIGENCE_API_KEY + ? new CopilotRuntime({ + agents: { ...agents, default: defaultAgent }, + licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN, + intelligence: new CopilotKitIntelligence({ + apiKey: process.env.CPK_INTELLIGENCE_API_KEY, + ...(process.env.INTELLIGENCE_API_URL + ? { apiUrl: process.env.INTELLIGENCE_API_URL } + : {}), + ...(process.env.INTELLIGENCE_GATEWAY_WS_URL + ? { wsUrl: process.env.INTELLIGENCE_GATEWAY_WS_URL } + : {}), + }), + identifyUser: (request) => + options.localDevelopment + ? resolveLocalRuntimeUser(request) + : resolveVerifiedRuntimeUser(request), + }) + : new CopilotRuntime({ + agents: { ...agents, default: defaultAgent }, + runner: new AgentCoreRunner(), + }); return createCopilotEndpoint({ runtime, basePath: "/copilotkit" }); } diff --git a/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/server.ts b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/server.ts index 1706a7324a..9919890e91 100644 --- a/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/server.ts +++ b/examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/server.ts @@ -18,7 +18,7 @@ import { serve } from "@hono/node-server"; import { buildApp } from "./runtime"; const PORT = parseInt(process.env.PORT ?? "3001"); -const app = buildApp(); +const app = buildApp({ localDevelopment: true }); serve({ fetch: app.fetch, port: PORT }, () => { console.log( diff --git a/examples/integrations/agentcore/infra-cdk/lib/amplify-hosting-stack.ts b/examples/integrations/agentcore/infra-cdk/lib/amplify-hosting-stack.ts index 40a3951ec2..1649402c71 100644 --- a/examples/integrations/agentcore/infra-cdk/lib/amplify-hosting-stack.ts +++ b/examples/integrations/agentcore/infra-cdk/lib/amplify-hosting-stack.ts @@ -2,8 +2,8 @@ import * as cdk from "aws-cdk-lib"; import * as amplify from "@aws-cdk/aws-amplify-alpha"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as iam from "aws-cdk-lib/aws-iam"; -import { Construct } from "constructs"; -import { AppConfig } from "./utils/config-manager"; +import type { Construct } from "constructs"; +import type { AppConfig } from "./utils/config-manager"; export interface AmplifyStackProps extends cdk.NestedStackProps { config: AppConfig; diff --git a/examples/integrations/agentcore/infra-cdk/lib/backend-stack.ts b/examples/integrations/agentcore/infra-cdk/lib/backend-stack.ts index b0cdbb0871..e14928d8c0 100644 --- a/examples/integrations/agentcore/infra-cdk/lib/backend-stack.ts +++ b/examples/integrations/agentcore/infra-cdk/lib/backend-stack.ts @@ -11,12 +11,17 @@ import * as bedrockagentcore from "aws-cdk-lib/aws-bedrockagentcore"; import * as lambda from "aws-cdk-lib/aws-lambda"; import * as ecr_assets from "aws-cdk-lib/aws-ecr-assets"; import * as cr from "aws-cdk-lib/custom-resources"; -import { Construct } from "constructs"; -import { AppConfig } from "./utils/config-manager"; +import type { Construct } from "constructs"; +import type { AppConfig } from "./utils/config-manager"; import { AgentCoreRole } from "./utils/agentcore-role"; import * as path from "path"; import * as fs from "fs"; import { execSync } from "child_process"; +import { + addAuthenticatedRuntimeMethod, + createRuntimeAuthorizer, + createRuntimeIntegration, +} from "./copilotkit-runtime-auth"; export interface BackendStackProps extends cdk.NestedStackProps { config: AppConfig; @@ -426,6 +431,23 @@ export class BackendStack extends cdk.NestedStack { AGENTCORE_AG_UI_URL: agentCoreAgUiUrl, COPILOTKIT_AGENT_NAME: config.backend?.pattern || "langgraph-single-agent", + CPK_INTELLIGENCE_API_KEY: cdk.SecretValue.secretsManager( + config.copilotkit_intelligence_api_key_secret_name, + { + versionId: process.env.CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID, + }, + ).unsafeUnwrap(), + COPILOTKIT_LICENSE_TOKEN: cdk.SecretValue.secretsManager( + config.copilotkit_license_token_secret_name, + { + versionId: process.env.COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID, + }, + ).unsafeUnwrap(), + CPK_TELEMETRY_ID: process.env.CPK_TELEMETRY_ID ?? "", + INTELLIGENCE_API_URL: + process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201", + INTELLIGENCE_GATEWAY_WS_URL: + process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401", }, timeout: cdk.Duration.seconds(30), memorySize: 1024, @@ -442,7 +464,7 @@ export class BackendStack extends cdk.NestedStack { description: "Standalone CopilotKit runtime API backed by Lambda", defaultCorsPreflightOptions: { allowOrigins: [frontendUrl, "http://localhost:3000"], - allowMethods: ["GET", "POST", "OPTIONS"], + allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization"], }, deployOptions: { @@ -450,28 +472,50 @@ export class BackendStack extends cdk.NestedStack { }, }); - const runtimeIntegration = new apigateway.LambdaIntegration( + const runtimeIntegration = createRuntimeIntegration( copilotKitRuntimeLambda, - { - responseTransferMode: apigateway.ResponseTransferMode.STREAM, - }, ); + const runtimeAuthorizer = createRuntimeAuthorizer(this, this.userPool); const runtimeResource = copilotKitApi.root.addResource("copilotkit"); - runtimeResource.addMethod("GET", runtimeIntegration, { - authorizationType: apigateway.AuthorizationType.NONE, - }); - runtimeResource.addMethod("POST", runtimeIntegration, { - authorizationType: apigateway.AuthorizationType.NONE, - }); + addAuthenticatedRuntimeMethod( + runtimeResource, + "GET", + runtimeIntegration, + runtimeAuthorizer, + ); + addAuthenticatedRuntimeMethod( + runtimeResource, + "POST", + runtimeIntegration, + runtimeAuthorizer, + ); const runtimeProxy = runtimeResource.addResource("{proxy+}"); - runtimeProxy.addMethod("GET", runtimeIntegration, { - authorizationType: apigateway.AuthorizationType.NONE, - }); - runtimeProxy.addMethod("POST", runtimeIntegration, { - authorizationType: apigateway.AuthorizationType.NONE, - }); + addAuthenticatedRuntimeMethod( + runtimeProxy, + "GET", + runtimeIntegration, + runtimeAuthorizer, + ); + addAuthenticatedRuntimeMethod( + runtimeProxy, + "POST", + runtimeIntegration, + runtimeAuthorizer, + ); + addAuthenticatedRuntimeMethod( + runtimeProxy, + "PATCH", + runtimeIntegration, + runtimeAuthorizer, + ); + addAuthenticatedRuntimeMethod( + runtimeProxy, + "DELETE", + runtimeIntegration, + runtimeAuthorizer, + ); this.copilotKitRuntimeUrl = copilotKitApi.urlForPath("/copilotkit"); @@ -702,16 +746,6 @@ export class BackendStack extends cdk.NestedStack { value: gateway.attrGatewayArn, description: "AgentCore Gateway ARN", }); - - new cdk.CfnOutput(this, "GatewayTargetId", { - value: gatewayTarget.ref, - description: "AgentCore Gateway Target ID", - }); - - new cdk.CfnOutput(this, "ToolLambdaArn", { - description: "ARN of the sample tool Lambda", - value: toolLambda.functionArn, - }); } private createMachineAuthentication(config: AppConfig): void { diff --git a/examples/integrations/agentcore/infra-cdk/lib/copilotkit-runtime-auth.ts b/examples/integrations/agentcore/infra-cdk/lib/copilotkit-runtime-auth.ts new file mode 100644 index 0000000000..6e040043ed --- /dev/null +++ b/examples/integrations/agentcore/infra-cdk/lib/copilotkit-runtime-auth.ts @@ -0,0 +1,38 @@ +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import type * as lambda from "aws-cdk-lib/aws-lambda"; +import type * as cognito from "aws-cdk-lib/aws-cognito"; +import type { Construct } from "constructs"; + +/** Build the streaming Lambda integration for the CopilotKit Runtime. */ +export function createRuntimeIntegration( + handler: lambda.IFunction, +): apigateway.LambdaIntegration { + return new apigateway.LambdaIntegration(handler, { + responseTransferMode: apigateway.ResponseTransferMode.STREAM, + }); +} + +/** Build the Cognito authorizer shared by every CopilotKit Runtime method. */ +export function createRuntimeAuthorizer( + scope: Construct, + userPool: cognito.IUserPool, +): apigateway.CognitoUserPoolsAuthorizer { + return new apigateway.CognitoUserPoolsAuthorizer( + scope, + "CopilotKitRuntimeAuthorizer", + { cognitoUserPools: [userPool] }, + ); +} + +/** Mount one Runtime method behind the required Cognito authorizer. */ +export function addAuthenticatedRuntimeMethod( + resource: apigateway.IResource, + httpMethod: "GET" | "POST" | "PATCH" | "DELETE", + integration: apigateway.Integration, + authorizer: apigateway.IAuthorizer, +): void { + resource.addMethod(httpMethod, integration, { + authorizationType: apigateway.AuthorizationType.COGNITO, + authorizer, + }); +} diff --git a/examples/integrations/agentcore/infra-cdk/lib/utils/config-manager.ts b/examples/integrations/agentcore/infra-cdk/lib/utils/config-manager.ts index 8bfcc0fc33..5959d9e0c0 100644 --- a/examples/integrations/agentcore/infra-cdk/lib/utils/config-manager.ts +++ b/examples/integrations/agentcore/infra-cdk/lib/utils/config-manager.ts @@ -29,6 +29,10 @@ export interface VpcConfig { export interface AppConfig { stack_name_base: string; admin_user_email?: string | null; + /** Secrets Manager name containing the managed CopilotKit Intelligence key. */ + copilotkit_intelligence_api_key_secret_name: string; + /** Secrets Manager name containing the pinned SDK compatibility token. */ + copilotkit_license_token_secret_name: string; backend: { pattern: string; deployment_type: DeploymentType; @@ -108,6 +112,10 @@ export class ConfigManager { return { stack_name_base: stackNameBase, admin_user_email: parsedConfig.admin_user_email || null, + copilotkit_intelligence_api_key_secret_name: + parsedConfig.copilotkit_intelligence_api_key_secret_name, + copilotkit_license_token_secret_name: + parsedConfig.copilotkit_license_token_secret_name, backend: { pattern: parsedConfig.backend?.pattern || "langgraph-single-agent", deployment_type: deploymentType, diff --git a/examples/integrations/agentcore/infra-cdk/test/copilotkit-runtime-auth.test.ts b/examples/integrations/agentcore/infra-cdk/test/copilotkit-runtime-auth.test.ts new file mode 100644 index 0000000000..9f045cc266 --- /dev/null +++ b/examples/integrations/agentcore/infra-cdk/test/copilotkit-runtime-auth.test.ts @@ -0,0 +1,124 @@ +import * as cdk from "aws-cdk-lib"; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { Template } from "aws-cdk-lib/assertions"; +import * as agentcore from "@aws-cdk/aws-bedrock-agentcore-alpha"; +import { + addAuthenticatedRuntimeMethod, + createRuntimeAuthorizer, + createRuntimeIntegration, +} from "../lib/copilotkit-runtime-auth"; +import { BackendStack } from "../lib/backend-stack"; +import type { AppConfig } from "../lib/utils/config-manager"; + +/** Build the smallest stack that uses the production Runtime auth helpers. */ +function setup() { + const app = new cdk.App(); + const stack = new cdk.Stack(app, "RuntimeAuthTestStack"); + const api = new apigateway.RestApi(stack, "RuntimeApi"); + const handler = new lambda.Function(stack, "RuntimeHandler", { + runtime: lambda.Runtime.NODEJS_20_X, + handler: "index.handler", + code: lambda.Code.fromInline( + "exports.handler = async () => ({ statusCode: 200 });", + ), + }); + const userPool = new cognito.UserPool(stack, "UserPool"); + const integration = createRuntimeIntegration(handler); + const authorizer = createRuntimeAuthorizer(stack, userPool); + const runtime = api.root.addResource("copilotkit"); + const proxy = runtime.addResource("{proxy+}"); + + addAuthenticatedRuntimeMethod(runtime, "GET", integration, authorizer); + addAuthenticatedRuntimeMethod(runtime, "POST", integration, authorizer); + addAuthenticatedRuntimeMethod(proxy, "GET", integration, authorizer); + addAuthenticatedRuntimeMethod(proxy, "POST", integration, authorizer); + + return { template: Template.fromStack(stack) }; +} + +test("synthesizes four Cognito-protected Runtime methods", () => { + const { template } = setup(); + + const methods = Object.values( + template.findResources("AWS::ApiGateway::Method"), + ); + + expect(methods).toHaveLength(4); + for (const method of methods) { + expect(method.Properties).toMatchObject({ + AuthorizationType: "COGNITO_USER_POOLS", + }); + expect(method.Properties.AuthorizerId).toBeDefined(); + } +}); + +test("synthesizes BackendStack with all authenticated Runtime methods", () => { + const app = new cdk.App(); + const parent = new cdk.Stack(app, "BackendTestParent", { + env: { account: "123456789012", region: "us-east-1" }, + }); + const userPool = new cognito.UserPool(parent, "BackendTestUserPool"); + const userPoolClient = new cognito.UserPoolClient( + parent, + "BackendTestUserPoolClient", + { userPool }, + ); + const userPoolDomain = new cognito.UserPoolDomain( + parent, + "BackendTestUserPoolDomain", + { + userPool, + cognitoDomain: { domainPrefix: "backend-runtime-test" }, + }, + ); + const config: AppConfig = { + stack_name_base: "backend-runtime-test", + copilotkit_intelligence_api_key_secret_name: "test/intelligence-key", + copilotkit_license_token_secret_name: "test/license-token", + backend: { + pattern: "langgraph-single-agent", + deployment_type: "docker", + network_mode: "PUBLIC", + }, + }; + const runtimeArtifact = agentcore.AgentRuntimeArtifact.fromImageUri( + "123456789012.dkr.ecr.us-east-1.amazonaws.com/runtime:test", + ); + const runtimeAsset = jest + .spyOn(agentcore.AgentRuntimeArtifact, "fromAsset") + .mockReturnValue(runtimeArtifact); + const lambdaAsset = jest + .spyOn(lambda.Code, "fromAsset") + .mockReturnValue( + lambda.Code.fromInline( + "exports.handler = async () => ({ statusCode: 200 });", + ) as unknown as lambda.AssetCode, + ); + + try { + const backend = new BackendStack(parent, "Backend", { + config, + userPoolId: userPool.userPoolId, + userPoolClientId: userPoolClient.userPoolClientId, + userPoolDomain, + frontendUrl: "https://frontend.example.com", + }); + const template = Template.fromStack(backend); + const authenticatedMethods = Object.values( + template.findResources("AWS::ApiGateway::Method"), + ).filter( + (method) => method.Properties.AuthorizationType === "COGNITO_USER_POOLS", + ); + + expect( + authenticatedMethods.map((method) => method.Properties.HttpMethod).sort(), + ).toEqual(["DELETE", "GET", "GET", "PATCH", "POST", "POST"]); + expect(template.toJSON().Outputs).not.toHaveProperty("GatewayTargetId"); + expect(template.toJSON().Outputs).not.toHaveProperty("ToolLambdaArn"); + } finally { + runtimeAsset.mockRestore(); + lambdaAsset.mockRestore(); + } +}); diff --git a/examples/integrations/agentcore/infra-terraform/README.md b/examples/integrations/agentcore/infra-terraform/README.md index 69bf01e0c0..9d53a76ef9 100644 --- a/examples/integrations/agentcore/infra-terraform/README.md +++ b/examples/integrations/agentcore/infra-terraform/README.md @@ -1,6 +1,9 @@ # Terraform Infrastructure -Equivalent of `../infra-cdk/` using Terraform. +Terraform support covers the base AgentCore agent, gateway, authentication, and +frontend infrastructure. It does not project managed Intelligence credentials +into the CopilotKit Runtime Lambda. Use the CDK deployment path documented in +`../README.md` when the managed Threads and Intelligence path is required. ## Usage diff --git a/scripts/__tests__/agentcore-deploy-skip-backend.test.ts b/scripts/__tests__/agentcore-deploy-skip-backend.test.ts new file mode 100644 index 0000000000..991c9b39cb --- /dev/null +++ b/scripts/__tests__/agentcore-deploy-skip-backend.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, test } from "vitest"; + +const DEPLOY_SCRIPTS = ["deploy-langgraph.sh", "deploy-strands.sh"] as const; + +/** Run a frontend-only deploy from a clean directory with no backend secrets. */ +function runFrontendOnly(scriptName: (typeof DEPLOY_SCRIPTS)[number]) { + const directory = mkdtempSync(join(tmpdir(), "agentcore-frontend-only-")); + const scriptPath = join(directory, scriptName); + const frontendMarker = join(directory, "frontend-ran"); + const fakeBin = join(directory, "bin"); + mkdirSync(fakeBin); + mkdirSync(join(directory, "scripts")); + copyFileSync( + resolve("examples/integrations/agentcore", scriptName), + scriptPath, + ); + chmodSync(scriptPath, 0o755); + writeFileSync( + join(directory, "config.yaml"), + [ + "stack_name_base: agentcore-contract", + "copilotkit_intelligence_api_key_secret_name: ignored", + "backend:", + " pattern: ignored", + ].join("\n"), + ); + writeFileSync( + join(directory, "scripts", "deploy-frontend.py"), + [ + "from pathlib import Path", + `Path(${JSON.stringify(frontendMarker)}).write_text('ran')`, + ].join("\n"), + ); + writeFileSync(join(fakeBin, "aws"), "#!/usr/bin/env bash\nexit 0\n"); + chmodSync(join(fakeBin, "aws"), 0o755); + + const result = spawnSync("/bin/bash", [scriptPath, "--skip-backend"], { + cwd: directory, + encoding: "utf8", + env: { + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + }, + }); + + return { + output: `${result.stdout}${result.stderr}`, + frontendRan: existsSync(frontendMarker) + ? readFileSync(frontendMarker, "utf8") + : null, + status: result.status, + teardown: () => rmSync(directory, { force: true, recursive: true }), + }; +} + +test.each(DEPLOY_SCRIPTS)( + "%s runs --skip-backend without a .env file or backend credentials", + (scriptName) => { + const result = runFrontendOnly(scriptName); + + try { + expect(result.status).toBe(0); + expect(result.frontendRan).toBe("ran"); + expect(result.output).toContain( + "Skipping backend deploy (--skip-backend)", + ); + expect(result.output).not.toContain("CPK_INTELLIGENCE_API_KEY"); + expect(result.output).not.toContain("INTELLIGENCE_API_URL"); + expect(result.output).not.toContain("INTELLIGENCE_GATEWAY_WS_URL"); + expect(result.output).not.toContain("Secrets Manager"); + } finally { + result.teardown(); + } + }, +); diff --git a/scripts/__tests__/agentcore-runtime-security.test.ts b/scripts/__tests__/agentcore-runtime-security.test.ts new file mode 100644 index 0000000000..6fe4c4ed67 --- /dev/null +++ b/scripts/__tests__/agentcore-runtime-security.test.ts @@ -0,0 +1,216 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, test, vi } from "vitest"; + +import { + createVerifiedRuntimeHandler, + resolveLocalRuntimeUser, + resolveVerifiedRuntimeUser, + VERIFIED_RUNTIME_USER_HEADER, + withVerifiedRuntimeUserHeader, +} from "../../examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/identity"; +import type { ApiGatewayRuntimeEvent } from "../../examples/integrations/agentcore/infra-cdk/lambdas/copilotkit-runtime/src/identity"; + +/** Read a tracked AgentCore source file from the repository root. */ +function readAgentCoreSource(relativePath: string): string { + return readFileSync( + resolve(process.cwd(), "examples/integrations/agentcore", relativePath), + "utf8", + ); +} + +test("AgentCore runtime methods require Cognito and map the verified subject", () => { + const backendSource = readAgentCoreSource("infra-cdk/lib/backend-stack.ts"); + const authSource = readAgentCoreSource( + "infra-cdk/lib/copilotkit-runtime-auth.ts", + ); + const runtimeApiSection = backendSource.slice( + backendSource.indexOf("const copilotKitApi"), + backendSource.indexOf("this.copilotKitRuntimeUrl"), + ); + + expect(authSource).toContain("CognitoUserPoolsAuthorizer"); + expect(authSource).toContain("AuthorizationType.COGNITO"); + expect(authSource).toContain( + 'httpMethod: "GET" | "POST" | "PATCH" | "DELETE"', + ); + expect( + runtimeApiSection.match(/addAuthenticatedRuntimeMethod\(/g), + ).toHaveLength(6); + expect(runtimeApiSection).toContain( + 'allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]', + ); + expect(runtimeApiSection).not.toContain("runtimeResource.addMethod"); + expect(runtimeApiSection).not.toContain("AuthorizationType.NONE"); +}); + +test("the AgentCore browser sends an ID token to identity-token Runtime methods", () => { + const source = readAgentCoreSource( + "frontend/src/components/chat/CopilotKit/index.tsx", + ); + + expect(source).toContain("const idToken = auth.user?.id_token;"); + expect(source).toContain("idToken={idToken}"); + expect(source).not.toContain("auth.user?.access_token"); +}); + +test("the deployed AgentCore runtime does not use one shared demo identity", () => { + const source = readAgentCoreSource( + "infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts", + ); + + expect(source).toContain("resolveVerifiedRuntimeUser(request)"); + expect(source).not.toContain('identifyUser: () => ({ id: "demo-user"'); +}); + +test("the deployed Lambda overwrites a caller identity before Hono receives it", () => { + const response = Object.freeze({ statusCode: 200 }); + const context = Object.freeze({ awsRequestId: "request-1" }); + const callback = vi.fn(); + const event: ApiGatewayRuntimeEvent = { + headers: { + "X-CopilotKit-Verified-User-Id": "attacker-user", + "x-safe-header": "preserved", + }, + requestContext: { + authorizer: { claims: { sub: "verified-cognito-user" } }, + }, + }; + const honoHandler = vi.fn().mockReturnValue(response); + const deployedRuntimeHandler = createVerifiedRuntimeHandler(honoHandler); + + const result = deployedRuntimeHandler(event, context, callback); + + expect(result).toBe(response); + expect(honoHandler).toHaveBeenCalledOnce(); + expect(honoHandler).toHaveBeenCalledWith( + { + ...event, + headers: { + [VERIFIED_RUNTIME_USER_HEADER]: "verified-cognito-user", + "x-safe-header": "preserved", + }, + }, + context, + callback, + ); + + const indexSource = readAgentCoreSource( + "infra-cdk/lambdas/copilotkit-runtime/src/index.ts", + ); + expect(indexSource).toContain( + "export const handler = createVerifiedRuntimeHandler(honoHandler);", + ); +}); + +test("AgentCore rejects a Runtime request without the trusted user header", () => { + const request = new Request("https://runtime.example/copilotkit/info"); + + expect(() => resolveVerifiedRuntimeUser(request)).toThrow( + "Verified Runtime user identity is required", + ); +}); + +test("AgentCore keeps two verified Cognito subjects isolated", () => { + const firstRequest = new Request("https://runtime.example/copilotkit/info", { + headers: { [VERIFIED_RUNTIME_USER_HEADER]: "cognito-user-a" }, + }); + const secondRequest = new Request("https://runtime.example/copilotkit/info", { + headers: { [VERIFIED_RUNTIME_USER_HEADER]: "cognito-user-b" }, + }); + + expect(resolveVerifiedRuntimeUser(firstRequest)).toEqual({ + id: "cognito-user-a", + name: "cognito-user-a", + }); + expect(resolveVerifiedRuntimeUser(secondRequest)).toEqual({ + id: "cognito-user-b", + name: "cognito-user-b", + }); +}); + +test("only the explicit local resolver supplies a demo user", () => { + const request = new Request("http://localhost:3001/copilotkit/info"); + + expect(resolveLocalRuntimeUser(request)).toEqual({ + id: "local-demo-user", + name: "Local Demo User", + }); +}); + +test("AgentCore Docker reaches host Intelligence without allowing that host in AWS", () => { + const environmentSource = readAgentCoreSource(".env.example"); + const composeSource = readAgentCoreSource("docker/docker-compose.yml"); + + expect(environmentSource).toContain( + "INTELLIGENCE_API_URL=http://host.docker.internal:4201", + ); + expect(environmentSource).toContain( + "INTELLIGENCE_GATEWAY_WS_URL=ws://host.docker.internal:4401", + ); + expect(composeSource).toContain("extra_hosts:"); + expect(composeSource).toContain("host.docker.internal:host-gateway"); + + for (const scriptName of ["deploy-langgraph.sh", "deploy-strands.sh"]) { + expect(readAgentCoreSource(scriptName)).toContain( + "host\\.docker\\.internal", + ); + } +}); + +test("the Lambda event bridge overwrites an attacker-controlled private header", () => { + const event = withVerifiedRuntimeUserHeader({ + headers: { + "X-CopilotKit-Verified-User-Id": "attacker-user", + "x-safe-header": "preserved", + }, + requestContext: { + authorizer: { claims: { sub: "verified-cognito-user" } }, + }, + }); + + expect(event.headers).toEqual({ + [VERIFIED_RUNTIME_USER_HEADER]: "verified-cognito-user", + "x-safe-header": "preserved", + }); +}); + +test("the Lambda event bridge maps two Cognito claims to distinct users", () => { + const first = withVerifiedRuntimeUserHeader({ + requestContext: { authorizer: { claims: { sub: "cognito-user-a" } } }, + }); + const second = withVerifiedRuntimeUserHeader({ + requestContext: { authorizer: { claims: { sub: "cognito-user-b" } } }, + }); + + expect(first.headers?.[VERIFIED_RUNTIME_USER_HEADER]).toBe("cognito-user-a"); + expect(second.headers?.[VERIFIED_RUNTIME_USER_HEADER]).toBe("cognito-user-b"); +}); + +test("the Lambda event bridge removes an attacker header when claims are missing", () => { + const event = withVerifiedRuntimeUserHeader({ + headers: { [VERIFIED_RUNTIME_USER_HEADER]: "attacker-user" }, + requestContext: { authorizer: { claims: {} } }, + }); + + expect(event.headers).toEqual({}); +}); + +test("the Lambda event bridge removes an attacker multi-value private header", () => { + const event = withVerifiedRuntimeUserHeader({ + multiValueHeaders: { + "X-CopilotKit-Verified-User-Id": ["attacker-user"], + "x-safe-header": ["preserved"], + }, + requestContext: { + authorizer: { claims: { sub: "verified-cognito-user" } }, + }, + }); + + expect(event.headers).toEqual({ + [VERIFIED_RUNTIME_USER_HEADER]: "verified-cognito-user", + }); + expect(event.multiValueHeaders).toEqual({ + "x-safe-header": ["preserved"], + }); +}); diff --git a/scripts/__tests__/integration-intelligence-migration.test.ts b/scripts/__tests__/integration-intelligence-migration.test.ts index 1fc58fdbca..b7742f5626 100644 --- a/scripts/__tests__/integration-intelligence-migration.test.ts +++ b/scripts/__tests__/integration-intelligence-migration.test.ts @@ -1,22 +1,93 @@ -import { describe, expect, it, test } from "vitest"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; +import * as ts from "typescript"; +import { expect, test } from "vitest"; + const repoRoot = path.resolve(__dirname, "..", ".."); const integrationsDir = path.join(repoRoot, "examples", "integrations"); -const migratedIntegrations = [ - "crewai-flows", - "llamaindex", - "langgraph-fastapi", +const MANAGED_API_KEY = "CPK_INTELLIGENCE_API_KEY"; +const MANAGED_API_KEY_SENTINEL = "cpk_secret_must_not_reach_browser_assets"; +const OPTIONAL_TELEMETRY_ID = "CPK_TELEMETRY_ID"; +const LEGACY_API_KEY = "INTELLIGENCE_API_KEY"; +const LEGACY_TELEMETRY_ID = "COPILOTKIT_TELEMETRY_ID"; +const MANAGED_LICENSE_TOKEN = "COPILOTKIT_LICENSE_TOKEN"; +const MANAGED_API_KEY_SECRET_CONFIG = + "copilotkit_intelligence_api_key_secret_name"; +const MANAGED_API_KEY_SECRET_VERSION_ID = + "CPK_INTELLIGENCE_API_KEY_SECRET_VERSION_ID"; +const MANAGED_API_KEY_SECRET_VERSION_SENTINEL = + "agentcore-secret-version-contract"; +const LEGACY_LICENSE_TOKEN_SECRET_CONFIG = + "copilotkit_license_token_secret_name"; +const LEGACY_LICENSE_TOKEN_SECRET_VERSION_ID = + "COPILOTKIT_LICENSE_TOKEN_SECRET_VERSION_ID"; +const LEGACY_LICENSE_TOKEN_SENTINEL = + "license_token_must_not_reach_child_processes"; +const LEGACY_LICENSE_TOKEN_SECRET_VERSION_SENTINEL = + "agentcore-license-token-version-contract"; +const INTELLIGENCE_API_URL = "INTELLIGENCE_API_URL"; +const INTELLIGENCE_GATEWAY_WS_URL = "INTELLIGENCE_GATEWAY_WS_URL"; +const NEXT_THREADS_GATE = "NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED"; +const VITE_THREADS_GATE = "VITE_COPILOTKIT_THREADS_ENABLED"; +const MANAGED_DOCUMENTATION_LABEL = /\bmanaged\b/i; +const SELF_HOSTED_OR_OFFLINE_LABEL = /\b(?:self[ -]?hosted|offline)\b/i; +const LICENSE_GATING_LANGUAGE = + /(?:\blicen[cs]e\b[\s\S]{0,160}\b(?:activat(?:e[sd]?|ing)|unlock(?:s|ed|ing)?|enabl(?:e[sd]?|ing))\b[\s\S]{0,160}\b(?:threads?|inspector)\b|\b(?:threads?|inspector)\b[\s\S]{0,160}\b(?:activat(?:e[sd]?|ing)|unlock(?:s|ed|ing)?|enabl(?:e[sd]?|ing))\b[\s\S]{0,160}\blicen[cs]e\b)/i; +const INTEGRATION_PARITY_WORKFLOW = path.join( + repoRoot, + ".github", + "workflows", + "integrations_parity.yml", +); + +type ManagedSdkPackageName = "@copilotkit/runtime" | "@copilotkit/react-core"; + +interface ManagedSdkPin { + readonly packageName: ManagedSdkPackageName; + readonly packagePath: string; + readonly version: string; +} + +const MANAGED_ENTITLEMENT_CONTRACT_BY_SDK_VERSION = { + "@copilotkit/runtime": { + "1.62.2": false, + "1.62.3": false, + }, + "@copilotkit/react-core": { + "1.62.2": false, + "1.62.3": false, + }, +} as const satisfies Readonly< + Record>> +>; + +const INTELLIGENCE_CLI_FRAMEWORKS = [ + "langgraph-py", + "langgraph-js", + "claude-sdk-typescript", + "claude-sdk-python", + "flows", + "mastra", "pydantic-ai", + "llamaindex", + "agno", + "adk", + "aws-strands-py", + "a2a", + "microsoft-agent-framework-dotnet", + "microsoft-agent-framework-py", "mcp-apps", - "agent-spec", - "strands-python", - "crewai-crews", + "agentcore-langgraph", + "agentcore-strands", + "a2ui", + "opengenui", ] as const; -const intelligenceDevStacks = [ + +const INTELLIGENCE_DEV_STACKS = [ { integration: "adk", projectName: "copilotkit-intelligence-dev-adk", @@ -74,7 +145,8 @@ const intelligenceDevStacks = [ gatewayPort: "4407", }, ] as const; -const expectedPostgresInitSql = [ + +const EXPECTED_POSTGRES_INIT_SQL = [ "-- Runs once on the postgres container's first boot (docker-entrypoint-initdb.d).", "-- The intelligence composite image's migrations oneshot + app-api connect to", "-- intelligence_app; graphile-migrate uses intelligence_app_shadow for its shadow", @@ -83,20 +155,366 @@ const expectedPostgresInitSql = [ "CREATE DATABASE intelligence_app_shadow;", "", ].join("\n"); -const a2aMiddlewareRoot = path.join(integrationsDir, "a2a-middleware"); -const appRoots: Record<(typeof migratedIntegrations)[number], string> = { - "crewai-flows": "src/app", - llamaindex: "src/app", - "langgraph-fastapi": "src/app", - "pydantic-ai": "src/app", - "mcp-apps": "app", - "agent-spec": "src/app", - "strands-python": "src/app", - "crewai-crews": "src/app", -}; +type ManagedCliFramework = (typeof INTELLIGENCE_CLI_FRAMEWORKS)[number]; -function readIntegrationFile( +interface AgentOptionContract { + readonly property: string; + readonly environmentReads?: readonly string[]; + readonly stringLiterals?: readonly string[]; +} + +type RuntimeAgentContract = + | { + readonly registration: "default"; + readonly constructorName: string; + readonly options: readonly AgentOptionContract[]; + } + | { + readonly registration: "factory"; + readonly calleePath: readonly string[]; + readonly argumentIdentifier: string; + }; + +interface ManagedTemplateContract { + readonly directory: string; + readonly frameworks: readonly ManagedCliFramework[]; + readonly runtimePath: string; + readonly gatePath: string; + readonly envPath: string; + readonly readmePath: string; + readonly runtimeAgent?: RuntimeAgentContract; + readonly supportedPaths?: { + readonly localComposePath: string; + readonly deploymentConfigPath: string; + readonly runtimeDeploymentPath: string; + readonly frontendDeploymentPath: string; + readonly terraformRuntimeDeploymentPath: string; + readonly terraformReadmePath: string; + readonly deployScriptPaths: readonly string[]; + }; +} + +type AgentCoreDeployScriptName = "deploy-langgraph.sh" | "deploy-strands.sh"; + +interface AgentCoreDeployEnvironment { + readonly apiUrl?: string; + readonly gatewayWsUrl?: string; +} + +interface AgentCoreDeployHarnessOptions { + readonly scriptName: AgentCoreDeployScriptName; + readonly envFile: AgentCoreDeployEnvironment; + readonly callerEnvironment?: AgentCoreDeployEnvironment; + readonly skipBackend?: boolean; + readonly skipFrontend?: boolean; + readonly xtrace?: boolean; +} + +interface AgentCoreDeployHarnessResult { + readonly status: number | null; + readonly output: string; + readonly cdkEnvironment: string | null; + readonly secretVersionId: string | null; + readonly licenseTokenSecretVersionId: string | null; + readonly awsCommands: readonly string[]; + readonly npmCommands: readonly string[]; + readonly frontendStackName: string | null; + readonly frontendSecretVersionId: string | null; + readonly secretExposureObservations: readonly string[]; + readonly secretInputObservations: readonly string[]; +} + +const LANGGRAPH_RUNTIME_AGENT_CONTRACT = { + registration: "default", + constructorName: "LangGraphAgent", + options: [ + { + property: "deploymentUrl", + environmentReads: ["AGENT_URL", "LANGGRAPH_DEPLOYMENT_URL"], + stringLiterals: ["http://localhost:8123"], + }, + { property: "graphId", stringLiterals: ["sample_agent"] }, + { + property: "langsmithApiKey", + environmentReads: ["LANGSMITH_API_KEY"], + }, + ], +} as const satisfies RuntimeAgentContract; + +const LANGGRAPH_FASTAPI_RUNTIME_AGENT_CONTRACT = { + registration: "default", + constructorName: "LangGraphHttpAgent", + options: [ + { + property: "url", + environmentReads: ["AGENT_URL"], + stringLiterals: ["http://localhost:8123"], + }, + ], +} as const satisfies RuntimeAgentContract; + +const HTTP_LOCALHOST_RUNTIME_AGENT_CONTRACT = { + registration: "default", + constructorName: "HttpAgent", + options: [ + { + property: "url", + environmentReads: ["AGENT_URL"], + stringLiterals: ["http://localhost:8000"], + }, + ], +} as const satisfies RuntimeAgentContract; + +const HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT = { + registration: "default", + constructorName: "HttpAgent", + options: [ + { + property: "url", + environmentReads: ["AGENT_URL"], + stringLiterals: ["http://localhost:8000/"], + }, + ], +} as const satisfies RuntimeAgentContract; + +const MASTRA_RUNTIME_AGENT_CONTRACT = { + registration: "factory", + calleePath: ["MastraAgent", "getLocalAgents"], + argumentIdentifier: "mastra", +} as const satisfies RuntimeAgentContract; + +const LLAMAINDEX_RUNTIME_AGENT_CONTRACT = { + registration: "default", + constructorName: "LlamaIndexAgent", + options: [ + { + property: "url", + environmentReads: ["AGENT_URL"], + stringLiterals: ["http://127.0.0.1:9000", "/run"], + }, + ], +} as const satisfies RuntimeAgentContract; + +const AGNO_RUNTIME_AGENT_CONTRACT = { + registration: "default", + constructorName: "HttpAgent", + options: [ + { + property: "url", + environmentReads: ["AGENT_URL"], + stringLiterals: ["http://localhost:8000", "/agui"], + }, + ], +} as const satisfies RuntimeAgentContract; + +const STRANDS_RUNTIME_AGENT_CONTRACT = { + registration: "default", + constructorName: "HttpAgent", + options: [ + { + property: "url", + environmentReads: ["AGENT_URL", "STRANDS_AGENT_URL"], + stringLiterals: ["http://localhost:8000"], + }, + ], +} as const satisfies RuntimeAgentContract; + +const INTELLIGENCE_TEMPLATE_CONTRACTS = [ + { + directory: "langgraph-python", + frameworks: ["langgraph-py", "a2ui", "opengenui"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: LANGGRAPH_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "langgraph-js", + frameworks: ["langgraph-js"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: LANGGRAPH_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "langgraph-fastapi", + frameworks: [], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: LANGGRAPH_FASTAPI_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "claude-sdk-typescript", + frameworks: ["claude-sdk-typescript"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: HTTP_LOCALHOST_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "claude-sdk-python", + frameworks: ["claude-sdk-python"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: HTTP_LOCALHOST_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "crewai-flows", + frameworks: ["flows"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: HTTP_LOCALHOST_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "mastra", + frameworks: ["mastra"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: MASTRA_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "pydantic-ai", + frameworks: ["pydantic-ai"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "llamaindex", + frameworks: ["llamaindex"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: LLAMAINDEX_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "agno", + frameworks: ["agno"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: AGNO_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "adk", + frameworks: ["adk"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "strands-python", + frameworks: ["aws-strands-py"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: STRANDS_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "a2a-middleware", + frameworks: ["a2a"], + runtimePath: "app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + }, + { + directory: "ms-agent-framework-dotnet", + frameworks: ["microsoft-agent-framework-dotnet"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "ms-agent-framework-python", + frameworks: ["microsoft-agent-framework-py"], + runtimePath: "src/app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + runtimeAgent: HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT, + }, + { + directory: "mcp-apps", + frameworks: ["mcp-apps"], + runtimePath: "app/api/copilotkit/[[...slug]]/route.ts", + gatePath: "next.config.ts", + envPath: ".env.example", + readmePath: "README.md", + }, + { + directory: "agentcore", + frameworks: ["agentcore-langgraph", "agentcore-strands"], + runtimePath: "infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts", + gatePath: "frontend/vite.config.ts", + envPath: ".env.example", + readmePath: "README.md", + supportedPaths: { + localComposePath: "docker/docker-compose.yml", + deploymentConfigPath: "config.yaml.example", + runtimeDeploymentPath: "infra-cdk/lib/backend-stack.ts", + frontendDeploymentPath: "infra-cdk/lib/amplify-hosting-stack.ts", + terraformRuntimeDeploymentPath: + "infra-terraform/modules/backend/copilotkit_runtime.tf", + terraformReadmePath: "infra-terraform/README.md", + deployScriptPaths: ["deploy-langgraph.sh", "deploy-strands.sh"], + }, + }, +] as const satisfies readonly ManagedTemplateContract[]; + +/** + * Reads one required managed-template surface from its authoritative path. + * + * @param contract - Managed template and its exact surface paths. + * @param relativePath - Surface path relative to the integration directory. + * @param surface - Human-readable surface name used in assertion failures. + * @returns The UTF-8 contents, or an empty string after a missing-file assertion. + */ +function readManagedSurface( + contract: ManagedTemplateContract, + relativePath: string, + surface: string, +): string { + const filePath = path.join(integrationsDir, contract.directory, relativePath); + const exists = fs.existsSync(filePath); + + expect( + exists, + `${contract.directory} ${surface} must exist at ${relativePath}`, + ).toBe(true); + + return exists ? fs.readFileSync(filePath, "utf8") : ""; +} + +/** + * Reads one local Intelligence stack surface. + * + * @param integration - Integration directory name. + * @param relativePath - Surface path relative to that integration. + * @returns The UTF-8 file contents. + */ +function readIntelligenceDevSurface( integration: string, relativePath: string, ): string { @@ -106,7 +524,14 @@ function readIntegrationFile( ); } -function readOptionalIntegrationFile( +/** + * Reads one optional local Intelligence stack surface. + * + * @param integration - Integration directory name. + * @param relativePath - Surface path relative to that integration. + * @returns The UTF-8 file contents, or an empty string when absent. + */ +function readOptionalIntelligenceDevSurface( integration: string, relativePath: string, ): string { @@ -114,10 +539,12 @@ function readOptionalIntegrationFile( return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; } -function readA2AMiddlewareFile(pathFromRoot: string): string { - return fs.readFileSync(path.join(a2aMiddlewareRoot, pathFromRoot), "utf8"); -} - +/** + * Replaces the expected per-starter Compose differences with stable markers. + * + * @param compose - Local Intelligence Compose source. + * @returns The normalized source for drift checks. + */ function normalizeIntelligenceComposeForDriftGuard(compose: string): string { return compose .replace(/^name: .+$/m, "name: ") @@ -151,7 +578,12 @@ function normalizeIntelligenceComposeForDriftGuard(compose: string): string { ); } -function assertDockerComposeConfigSucceeds(integration: string): void { +/** + * Asserts that Docker Compose accepts one local Intelligence stack. + * + * @param integration - Integration directory name. + */ +function expectDockerComposeConfigToSucceed(integration: string): void { const composePath = path.join( integrationsDir, integration, @@ -180,463 +612,3901 @@ function assertDockerComposeConfigSucceeds(integration: string): void { } } -describe("starter local Intelligence docker stacks", () => { - for (const stack of intelligenceDevStacks) { - it(`${stack.integration} has a valid local Intelligence compose stack`, () => { - const compose = readIntegrationFile( - stack.integration, - "docker-compose.intelligence.yml", - ); - const postgresInit = readIntegrationFile( - stack.integration, - "docker/postgres-init/01-create-databases.sql", - ); - const readme = readIntegrationFile(stack.integration, "README.md"); - const envExample = readOptionalIntegrationFile( - stack.integration, - ".env.example", - ); +/** + * Returns one two-space-indented YAML mapping section. + * + * @param contents - YAML source containing service-style mappings. + * @param name - Mapping key whose complete section should be returned. + * @returns The mapping section, or an empty string when it is absent. + */ +function yamlMappingSection(contents: string, name: string): string { + const lines = contents.split(/\r?\n/); + const start = lines.findIndex((line) => line === ` ${name}:`); + if (start < 0) { + return ""; + } - assertDockerComposeConfigSucceeds(stack.integration); - expect(compose).toContain(`name: ${stack.projectName}`); - expect(compose).toContain( - `\${POSTGRES_HOST_PORT:-${stack.postgresPort}}:5432`, - ); - expect(compose).toContain(`\${REDIS_HOST_PORT:-${stack.redisPort}}:6379`); - expect(compose).toContain( - `\${APP_API_HOST_PORT:-${stack.appApiPort}}:4201`, - ); - expect(compose).toContain( - `\${GATEWAY_HOST_PORT:-${stack.gatewayPort}}:4401`, - ); - expect(compose).toContain( - "./docker/postgres-init:/docker-entrypoint-initdb.d:ro", - ); - expect(compose).not.toContain("./docker:/docker-entrypoint-initdb.d"); - expect(compose).toContain( - "pg_isready -U intelligence -d intelligence_app", - ); - expect(postgresInit).toBe(expectedPostgresInitSql); - expect(readme).toContain( - "## CopilotKit Intelligence & Threads (Optional)", - ); - expect(readme).toContain("docker-compose.intelligence.yml"); - expect(readme).toContain( - `INTELLIGENCE_API_URL=http://localhost:${stack.appApiPort}`, - ); - expect(readme).toContain( - `INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:${stack.gatewayPort}`, - ); + const relativeEnd = lines + .slice(start + 1) + .findIndex((line) => /^ [^\s].*:\s*$/.test(line)); + const end = relativeEnd < 0 ? lines.length : start + relativeEnd + 1; + return lines.slice(start, end).join("\n"); +} - if (envExample) { - expect(envExample).toContain( - `INTELLIGENCE_API_URL=http://localhost:${stack.appApiPort}`, - ); - expect(envExample).toContain( - `INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:${stack.gatewayPort}`, - ); +/** Matches a service-level reference to the generated project's root env. */ +function rootManagedEnvFilePattern(): RegExp { + return /env_file:\s*(?:\r?\n\s*-\s*)?\.\.\/\.env\b/; +} + +/** + * Matches an environment identifier without matching it inside a longer name. + * + * @param identifier - Exact uppercase environment identifier to match. + * @returns A pattern that excludes surrounding uppercase identifier characters. + */ +function exactEnvIdentifierPattern(identifier: string): RegExp { + return new RegExp(`(^|[^A-Z0-9_])${identifier}([^A-Z0-9_]|$)`); +} + +/** Returns whether a node is the `process.env` object expression. */ +function isProcessEnvObject(node: ts.Node): boolean { + return ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "process" && + node.name.text === "env" + ); +} + +/** + * Returns whether a node is a dot or bracket read from `process.env`. + * + * @param node - TypeScript syntax node under inspection. + * @param identifier - Exact environment identifier that must be read. + */ +function isProcessEnvRead(node: ts.Node, identifier: string): boolean { + if (ts.isPropertyAccessExpression(node)) { + return isProcessEnvObject(node.expression) && node.name.text === identifier; + } + + return ( + ts.isElementAccessExpression(node) && + isProcessEnvObject(node.expression) && + ts.isStringLiteral(node.argumentExpression) && + node.argumentExpression.text === identifier + ); +} + +/** Returns the static text of an object-literal property name when available. */ +function propertyNameText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) { + return name.text; + } + + return null; +} + +/** Parses managed scaffold TypeScript and rejects parser-recovered source. */ +function parseManagedSource(contents: string): ts.SourceFile { + const transpiled = ts.transpileModule(contents, { + compilerOptions: { + jsx: ts.JsxEmit.Preserve, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.Latest, + }, + fileName: "managed-integration-contract.tsx", + reportDiagnostics: true, + }); + const parseDiagnostics = + transpiled.diagnostics?.filter( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error, + ) ?? []; + expect( + parseDiagnostics, + "managed scaffold source must parse without diagnostics", + ).toHaveLength(0); + + const sourceFile = ts.createSourceFile( + "managed-integration-contract.tsx", + contents, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + + return sourceFile; +} + +/** Returns an expression without transparent TypeScript wrappers. */ +function unwrapExpression(expression: ts.Expression): ts.Expression { + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isNonNullExpression(expression) + ) { + return unwrapExpression(expression.expression); + } + + return expression; +} + +/** Returns one exact object-literal property assignment when present. */ +function objectPropertyAssignment( + objectLiteral: ts.ObjectLiteralExpression, + name: string, +): ts.PropertyAssignment | null { + for (const property of objectLiteral.properties) { + if ( + ts.isPropertyAssignment(property) && + propertyNameText(property.name) === name + ) { + return property; + } + } + + return null; +} + +/** Returns whether one expression contains the exact managed env read. */ +function expressionContainsEnvRead( + expression: ts.Expression, + identifier: string, +): boolean { + let found = false; + + /** Visits one initializer node looking for the required env read. */ + function visit(node: ts.Node): void { + if (found) { + return; + } + if (isProcessEnvRead(node, identifier)) { + found = true; + return; + } + + ts.forEachChild(node, visit); + } + + visit(expression); + return found; +} + +/** Returns whether an expression contains one exact string literal. */ +function expressionContainsStringLiteral( + expression: ts.Expression, + expected: string, +): boolean { + let found = false; + + /** Visits one initializer node looking for the required literal. */ + function visit(node: ts.Node): void { + if (found) return; + if ( + (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && + node.text === expected + ) { + found = true; + return; + } + + ts.forEachChild(node, visit); + } + + visit(expression); + return found; +} + +/** Returns whether an expression uses the managed key without telemetry. */ +function expressionUsesManagedKeyWithoutTelemetry( + expression: ts.Expression, +): boolean { + return ( + expressionContainsEnvRead(expression, MANAGED_API_KEY) && + !expressionContainsEnvRead(expression, OPTIONAL_TELEMETRY_ID) + ); +} + +/** Returns whether an expression is a literal boolean string projection. */ +function isBooleanStringProjection(expression: ts.Expression): boolean { + const unwrapped = unwrapExpression(expression); + if (ts.isStringLiteral(unwrapped)) { + return unwrapped.text === "true" || unwrapped.text === "false"; + } + if (ts.isConditionalExpression(unwrapped)) { + return ( + isBooleanStringProjection(unwrapped.whenTrue) && + isBooleanStringProjection(unwrapped.whenFalse) + ); + } + if ( + ts.isCallExpression(unwrapped) && + ts.isPropertyAccessExpression(unwrapped.expression) && + ts.isIdentifier(unwrapped.expression.expression) && + unwrapped.expression.expression.text === "JSON" && + unwrapped.expression.name.text === "stringify" && + unwrapped.arguments.length === 1 + ) { + return isBooleanStringProjection(unwrapped.arguments[0]!); + } + + return false; +} + +/** Returns whether the public gate is boolean-only and keyed by the CPK credential. */ +function expressionUsesManagedBooleanGate(expression: ts.Expression): boolean { + return ( + expressionContainsEnvRead(expression, MANAGED_API_KEY) && + !expressionContainsEnvRead(expression, OPTIONAL_TELEMETRY_ID) && + isBooleanStringProjection(expression) + ); +} + +/** Returns the expression assigned to one exact object-literal property. */ +function objectPropertyExpression( + objectLiteral: ts.ObjectLiteralExpression, + name: string, +): ts.Expression | null { + for (const property of objectLiteral.properties) { + if ( + ts.isPropertyAssignment(property) && + propertyNameText(property.name) === name + ) { + return property.initializer; + } + if ( + ts.isShorthandPropertyAssignment(property) && + property.name.text === name + ) { + return property.name; + } + } + + return null; +} + +/** Returns whether the Runtime's Intelligence client owns the API-key read. */ +function hasRuntimeApiKeyRead(sourceFile: ts.SourceFile): boolean { + const clientUsesManagedKey = (clientExpression: ts.Expression): boolean => { + const client = resolveTopLevelExpression(sourceFile, clientExpression); + if ( + !ts.isNewExpression(client) || + !ts.isIdentifier(client.expression) || + client.expression.text !== "CopilotKitIntelligence" + ) { + return false; + } + + const options = client.arguments?.[0] + ? unwrapExpression(client.arguments[0]) + : null; + if (!options || !ts.isObjectLiteralExpression(options)) return false; + + const apiKey = objectPropertyAssignment(options, "apiKey"); + return Boolean( + apiKey && + expressionUsesManagedKeyWithoutTelemetry( + resolveTopLevelExpression(sourceFile, apiKey.initializer), + ), + ); + }; + + return newExpressionOptions(sourceFile, "CopilotRuntime").some( + (runtimeOptions) => { + const directIntelligence = objectPropertyExpression( + runtimeOptions, + "intelligence", + ); + if (directIntelligence && clientUsesManagedKey(directIntelligence)) { + return true; } + + let found = false; + const visit = (node: ts.Node): void => { + if ( + ts.isNewExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "CopilotKitIntelligence" + ) { + found = clientUsesManagedKey(node); + } + if (!found) ts.forEachChild(node, visit); + }; + + visit(runtimeOptions); + return found; + }, + ); +} + +/** Returns the initializer for one top-level variable identifier. */ +function topLevelVariableInitializer( + sourceFile: ts.SourceFile, + identifier: string, +): ts.Expression | null { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) { + continue; + } + + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === identifier + ) { + return declaration.initializer ?? null; + } + } + } + + return null; +} + +/** Returns every variable initializer with one exact identifier in a source tree. */ +function variableInitializers( + root: ts.Node, + identifier: string, +): readonly ts.Expression[] { + const matches: ts.Expression[] = []; + + /** Visits variable declarations for the requested identifier. */ + function visit(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === identifier && + node.initializer + ) { + matches.push(node.initializer); + } + ts.forEachChild(node, visit); + } + + visit(root); + return matches; +} + +/** Resolves transparent wrappers and top-level variable aliases. */ +function resolveTopLevelExpression( + sourceFile: ts.SourceFile, + expression: ts.Expression, + seenIdentifiers: ReadonlySet = new Set(), +): ts.Expression { + const unwrapped = unwrapExpression(expression); + if (!ts.isIdentifier(unwrapped) || seenIdentifiers.has(unwrapped.text)) { + return unwrapped; + } + + const initializer = topLevelVariableInitializer(sourceFile, unwrapped.text); + if (!initializer) return unwrapped; + + return resolveTopLevelExpression( + sourceFile, + initializer, + new Set([...seenIdentifiers, unwrapped.text]), + ); +} + +/** Resolves one registered agent through an optional top-level identifier. */ +function registeredAgentExpression( + sourceFile: ts.SourceFile, + expression: ts.Expression, +): ts.Expression { + const unwrapped = unwrapExpression(expression); + if (!ts.isIdentifier(unwrapped)) return unwrapped; + + const initializer = topLevelVariableInitializer(sourceFile, unwrapped.text); + return initializer ? unwrapExpression(initializer) : unwrapped; +} + +/** Returns whether an object property carries one exact identifier. */ +function objectPropertyIsIdentifier( + objectLiteral: ts.ObjectLiteralExpression, + propertyName: string, + identifier: string, +): boolean { + return objectLiteral.properties.some((property) => { + if ( + ts.isShorthandPropertyAssignment(property) && + property.name.text === propertyName + ) { + return property.name.text === identifier; + } + if ( + ts.isPropertyAssignment(property) && + propertyNameText(property.name) === propertyName + ) { + const initializer = unwrapExpression(property.initializer); + return ts.isIdentifier(initializer) && initializer.text === identifier; + } + + return false; + }); +} + +/** Resolves a default-exported object literal through top-level variables. */ +function exportedObjectLiteral( + expression: ts.Expression, + sourceFile: ts.SourceFile, + seenIdentifiers: ReadonlySet = new Set(), +): ts.ObjectLiteralExpression | null { + const unwrapped = unwrapExpression(expression); + if (ts.isObjectLiteralExpression(unwrapped)) { + return unwrapped; + } + if (ts.isIdentifier(unwrapped) && !seenIdentifiers.has(unwrapped.text)) { + const initializer = topLevelVariableInitializer(sourceFile, unwrapped.text); + if (initializer) { + return exportedObjectLiteral( + initializer, + sourceFile, + new Set([...seenIdentifiers, unwrapped.text]), + ); + } + } + + return null; +} + +/** Returns the source's default export expression when it has one. */ +function defaultExportExpression( + sourceFile: ts.SourceFile, +): ts.Expression | null { + for (const statement of sourceFile.statements) { + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + return statement.expression; + } + } + + return null; +} + +/** Returns whether the exported Next thread gate owns the key read. */ +function hasExportedGateApiKeyRead(sourceFile: ts.SourceFile): boolean { + const exported = defaultExportExpression(sourceFile); + if (!exported) { + return false; + } + + const nextConfig = exportedObjectLiteral(exported, sourceFile); + return Boolean( + nextConfig && + nestedPropertyMatches( + nextConfig, + "env", + NEXT_THREADS_GATE, + expressionUsesManagedBooleanGate, + ), + ); +} + +/** Returns whether a nested object property satisfies an expression predicate. */ +function nestedPropertyMatches( + objectLiteral: ts.ObjectLiteralExpression, + containerName: string, + propertyName: string, + predicate: (expression: ts.Expression) => boolean, +): boolean { + const container = objectPropertyAssignment(objectLiteral, containerName); + if (!container) { + return false; + } + const containerInitializer = unwrapExpression(container.initializer); + if (!ts.isObjectLiteralExpression(containerInitializer)) { + return false; + } + const property = objectPropertyAssignment(containerInitializer, propertyName); + return Boolean(property && predicate(property.initializer)); +} + +/** Returns the exact dotted parts of a property-access expression. */ +function propertyAccessParts(expression: ts.Expression): readonly string[] { + const unwrapped = unwrapExpression(expression); + if (ts.isIdentifier(unwrapped)) { + return [unwrapped.text]; + } + if (ts.isPropertyAccessExpression(unwrapped)) { + return [...propertyAccessParts(unwrapped.expression), unwrapped.name.text]; + } + + return []; +} + +/** Returns whether an expression contains an exact dotted property path. */ +function expressionContainsPropertyPath( + expression: ts.Expression, + expectedPath: readonly string[], +): boolean { + let found = false; + + /** Visits one expression node for the exact configured path. */ + function visit(node: ts.Node): void { + if (found) return; + if ( + ts.isExpression(node) && + propertyAccessParts(node).join(".") === expectedPath.join(".") + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + } + + visit(expression); + return found; +} + +/** Returns whether an expression resolves a value through Secrets Manager. */ +function expressionContainsSecretResolution( + expression: ts.Expression, +): boolean { + let found = false; + + /** Visits one expression node for an explicit Secrets Manager call. */ + function visit(node: ts.Node): void { + if (found) return; + if (ts.isCallExpression(node)) { + const callee = propertyAccessParts(node.expression); + if ( + callee.at(-1) === "secretsManager" && + callee.includes("SecretValue") + ) { + found = true; + return; + } + } + ts.forEachChild(node, visit); + } + + visit(expression); + return found; +} + +/** Returns all new-expression option objects matching a construct name and id. */ +function constructOptions( + sourceFile: ts.SourceFile, + constructorPath: readonly string[], + constructId: string, +): readonly ts.ObjectLiteralExpression[] { + const matches: ts.ObjectLiteralExpression[] = []; + + /** Visits construct calls for exact constructor and id ownership. */ + function visit(node: ts.Node): void { + if ( + ts.isNewExpression(node) && + propertyAccessParts(node.expression).join(".") === + constructorPath.join(".") && + node.arguments?.[1] && + ts.isStringLiteral(node.arguments[1]) && + node.arguments[1].text === constructId + ) { + const options = node.arguments[2] + ? unwrapExpression(node.arguments[2]) + : null; + if (options && ts.isObjectLiteralExpression(options)) { + matches.push(options); + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return matches; +} + +/** Returns every object-literal options argument for one constructor. */ +function newExpressionOptions( + sourceFile: ts.SourceFile, + constructorName: string, +): readonly ts.ObjectLiteralExpression[] { + const matches: ts.ObjectLiteralExpression[] = []; + + /** Visits constructor calls for exact constructor ownership. */ + function visit(node: ts.Node): void { + if ( + ts.isNewExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === constructorName + ) { + const options = node.arguments?.[0] + ? unwrapExpression(node.arguments[0]) + : null; + if (options && ts.isObjectLiteralExpression(options)) { + matches.push(options); + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return matches; +} + +/** Returns one constructor's object-literal options from an exact expression. */ +function constructorOptionsFromExpression( + expression: ts.Expression, + constructorName: string, +): ts.ObjectLiteralExpression | null { + const unwrapped = unwrapExpression(expression); + if ( + !ts.isNewExpression(unwrapped) || + !ts.isIdentifier(unwrapped.expression) || + unwrapped.expression.text !== constructorName + ) { + return null; + } + + const options = unwrapped.arguments?.[0] + ? unwrapExpression(unwrapped.arguments[0]) + : null; + return options && ts.isObjectLiteralExpression(options) ? options : null; +} + +/** Returns whether a source contains a call to one exact function or method. */ +function sourceContainsCall( + sourceFile: ts.SourceFile, + calleePath: readonly string[], +): boolean { + let found = false; + + /** Visits call expressions until the required callee is found. */ + function visit(node: ts.Node): void { + if (found) return; + if ( + ts.isCallExpression(node) && + propertyAccessParts(node.expression).join(".") === calleePath.join(".") + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return found; +} + +/** Returns calls to one exact function or method. */ +function sourceCalls( + sourceFile: ts.SourceFile, + calleePath: readonly string[], +): readonly ts.CallExpression[] { + const matches: ts.CallExpression[] = []; + + /** Visits source nodes for the required callee. */ + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + propertyAccessParts(node.expression).join(".") === calleePath.join(".") + ) { + matches.push(node); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return matches; +} + +/** Returns whether source contains an exact environment read. */ +function sourceContainsEnvRead( + sourceFile: ts.SourceFile, + identifier: string, +): boolean { + let found = false; + + /** Visits source nodes for the required environment read. */ + function visit(node: ts.Node): void { + if (found) return; + if (isProcessEnvRead(node, identifier)) { + found = true; + return; + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return found; +} + +/** Returns the exported HTTP methods assigned to `handle(app)`. */ +function exportedEndpointHandlers( + sourceFile: ts.SourceFile, +): ReadonlySet { + const methods = new Set(); + + for (const statement of sourceFile.statements) { + if ( + !ts.isVariableStatement(statement) || + !statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ) + ) { + continue; + } + + for (const declaration of statement.declarationList.declarations) { + const initializer = declaration.initializer + ? unwrapExpression(declaration.initializer) + : null; + if ( + ts.isIdentifier(declaration.name) && + initializer && + ts.isCallExpression(initializer) && + ts.isIdentifier(initializer.expression) && + initializer.expression.text === "handle" && + initializer.arguments.length === 1 && + ts.isIdentifier(initializer.arguments[0]) && + initializer.arguments[0].text === "app" + ) { + methods.add(declaration.name.text); + } + } + } + + return methods; +} + +/** Returns static JSX tag text when the tag is not computed. */ +function jsxTagNameText(name: ts.JsxTagNameExpression): string | null { + if (ts.isIdentifier(name)) { + return name.text; + } + if (ts.isPropertyAccessExpression(name)) { + return propertyAccessParts(name).join("."); + } + + return null; +} + +/** Returns all JSX elements and self-closing elements with one tag name. */ +function jsxNodesWithTag( + root: ts.Node, + tagName: string, +): readonly (ts.JsxElement | ts.JsxSelfClosingElement)[] { + const matches: (ts.JsxElement | ts.JsxSelfClosingElement)[] = []; + + /** Visits JSX nodes for the exact tag name. */ + function visit(node: ts.Node): void { + if ( + ts.isJsxElement(node) && + jsxTagNameText(node.openingElement.tagName) === tagName + ) { + matches.push(node); + } else if ( + ts.isJsxSelfClosingElement(node) && + jsxTagNameText(node.tagName) === tagName + ) { + matches.push(node); + } + ts.forEachChild(node, visit); + } + + visit(root); + return matches; +} + +/** Returns the opening-like node for a JSX element. */ +function jsxOpeningLikeElement( + node: ts.JsxElement | ts.JsxSelfClosingElement, +): ts.JsxOpeningLikeElement { + return ts.isJsxElement(node) ? node.openingElement : node; +} + +/** Returns one exact JSX attribute. */ +function jsxAttribute( + node: ts.JsxElement | ts.JsxSelfClosingElement, + attributeName: string, +): ts.JsxAttribute | null { + const opening = jsxOpeningLikeElement(node); + for (const property of opening.attributes.properties) { + if ( + ts.isJsxAttribute(property) && + property.name.getText() === attributeName + ) { + return property; + } + } + + return null; +} + +/** Returns a stable structural identity for one JSX attribute value. */ +function jsxAttributeIdentity( + attribute: ts.JsxAttribute | null, +): string | null { + if (!attribute?.initializer) { + return attribute ? "true" : null; + } + if (ts.isStringLiteral(attribute.initializer)) { + return JSON.stringify(attribute.initializer.text); + } + if ( + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression + ) { + return attribute.initializer.expression.getText(); + } + + return null; +} + +/** Returns whether a JSX attribute is the boolean literal `false`. */ +function jsxAttributeIsFalse(attribute: ts.JsxAttribute | null): boolean { + return Boolean( + attribute?.initializer && + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression?.kind === ts.SyntaxKind.FalseKeyword, + ); +} + +/** Returns whether an object property is one exact string literal. */ +function objectPropertyIsString( + objectLiteral: ts.ObjectLiteralExpression, + propertyName: string, + expected: string, +): boolean { + const property = objectPropertyAssignment(objectLiteral, propertyName); + if (!property) return false; + const initializer = unwrapExpression(property.initializer); + return ts.isStringLiteral(initializer) && initializer.text === expected; +} + +/** + * Matches a documented environment assignment, including commented examples. + * + * @param identifier - Exact environment identifier expected before `=`. + * @returns A multiline assignment pattern. + */ +function envAssignmentPattern(identifier: string): RegExp { + return new RegExp(`^\\s*#?\\s*${identifier}\\s*=`, "m"); +} + +/** Returns every blank-line-delimited block in block-oriented text. */ +function textBlocks(contents: string): readonly string[] { + return contents.split(/\r?\n\s*\r?\n/); +} + +/** Returns the first blank-line-delimited text block containing a marker. */ +function textBlockContaining(contents: string, marker: string): string { + return textBlocks(contents).find((block) => block.includes(marker)) ?? ""; +} + +/** Asserts telemetry copy describes identity without prerequisite semantics. */ +function expectTelemetryIdentityDocumentation(contents: string): void { + const telemetryBlock = textBlockContaining(contents, OPTIONAL_TELEMETRY_ID); + + expect(telemetryBlock).toMatch(/\boptional\b/i); + expect(telemetryBlock).toMatch(/\bnon[- ]?secret\b/i); + expect(telemetryBlock).toMatch(/\banalytics\b/i); + expect(telemetryBlock).toMatch(/\bidentity\b/i); + expect(telemetryBlock).not.toMatch( + /\b(?:auth(?:entication|orization)?|entitlements?|enabl(?:e[sd]?|ing|ement)|consent(?:s|ed|ing)?|send(?:s|ing)?|sent|transmit(?:s|ted|ting)?)\b/i, + ); +} + +/** Returns whether an env block has an explicit comment label. */ +function envBlockHasLabel(block: string, label: RegExp): boolean { + return block + .split(/\r?\n/) + .some((line) => /^\s*#/.test(line) && label.test(line)); +} + +interface ManagedEnvSections { + readonly apiKey: string; + readonly telemetry: string; +} + +/** Returns the exact ordered managed dotenv sections from the public contract. */ +function managedEnvSections(contents: string): ManagedEnvSections | null { + const lines = contents.replaceAll("\r\n", "\n").split("\n"); + const apiHeader = "# Your CopilotKit Enterprise Intelligence API Key"; + const telemetryHeader = "# CopilotKit Telemetry ID"; + if ( + lines.filter((line) => line === apiHeader).length !== 1 || + lines.filter((line) => line === telemetryHeader).length !== 1 || + lines.filter((line) => envAssignmentPattern(MANAGED_API_KEY).test(line)) + .length !== 1 || + lines.filter((line) => + envAssignmentPattern(OPTIONAL_TELEMETRY_ID).test(line), + ).length !== 1 + ) { + return null; + } + const apiHeaderIndex = lines.findIndex((line) => line === apiHeader); + if (apiHeaderIndex < 0) return null; + + const projectName = lines[apiHeaderIndex + 1] ?? ""; + const apiKeyAssignment = lines[apiHeaderIndex + 2] ?? ""; + const separator = lines[apiHeaderIndex + 3]; + const telemetryHeaderIndex = apiHeaderIndex + 4; + if ( + !/^# Project Name: \S.*$/u.test(projectName) || + !envAssignmentPattern(MANAGED_API_KEY).test(apiKeyAssignment) || + separator !== "" || + lines[telemetryHeaderIndex] !== telemetryHeader + ) { + return null; + } + + let telemetryEnd = telemetryHeaderIndex + 1; + while (telemetryEnd < lines.length && lines[telemetryEnd] !== "") { + telemetryEnd += 1; + } + const telemetry = lines.slice(telemetryHeaderIndex, telemetryEnd).join("\n"); + if (!envAssignmentPattern(OPTIONAL_TELEMETRY_ID).test(telemetry)) { + return null; + } + + return { + apiKey: lines.slice(apiHeaderIndex, apiHeaderIndex + 3).join("\n"), + telemetry, + }; +} + +/** Asserts every env license occurrence has a deployment-mode label. */ +function expectEnvLicenseOccurrencesClassified(contents: string): void { + const licenseBlocks = textBlocks(contents).filter( + (block) => + block.includes(MANAGED_LICENSE_TOKEN) || + LICENSE_GATING_LANGUAGE.test(block), + ); + + expect( + licenseBlocks.every((block) => + envBlockHasLabel(block, SELF_HOSTED_OR_OFFLINE_LABEL), + ), + ).toBe(true); +} + +/** Returns Markdown paragraph starts containing license literals or gating copy. */ +function markdownLicenseOccurrenceLines(lines: readonly string[]): number[] { + const occurrences: number[] = []; + let start = 0; + while (start < lines.length) { + while (start < lines.length && lines[start]?.trim() === "") start += 1; + if (start >= lines.length) break; + let end = start + 1; + while (end < lines.length && lines[end]?.trim() !== "") end += 1; + const block = lines.slice(start, end).join("\n"); + if ( + block.includes(MANAGED_LICENSE_TOKEN) || + LICENSE_GATING_LANGUAGE.test(block) + ) { + occurrences.push(start); + } + start = end + 1; + } + + return occurrences; +} + +interface MarkdownHeadingSection { + readonly heading: string; + readonly contents: string; +} + +/** Returns every Markdown heading section through its next peer heading. */ +function markdownHeadingSections( + markdown: string, +): readonly MarkdownHeadingSection[] { + const lines = markdown.split(/\r?\n/); + const sections: MarkdownHeadingSection[] = []; + + for (let start = 0; start < lines.length; start += 1) { + const heading = lines[start]?.match(/^(#{1,6})\s+(.+?)\s*$/); + if (!heading) continue; + + const headingLevel = heading[1]?.length ?? 0; + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + const subsequent = lines[index]?.match(/^(#{1,6})\s+/); + if (subsequent && (subsequent[1]?.length ?? 0) <= headingLevel) { + end = index; + break; + } + } + + sections.push({ + heading: heading[2] ?? "", + contents: lines.slice(start, end).join("\n"), }); } - test("local Intelligence compose host defaults do not collide", () => { - const projectNames = new Set( - intelligenceDevStacks.map((stack) => stack.projectName), - ); - const postgresPorts = new Set( - intelligenceDevStacks.map((stack) => stack.postgresPort), - ); - const redisPorts = new Set( - intelligenceDevStacks.map((stack) => stack.redisPort), - ); - const appApiPorts = new Set( - intelligenceDevStacks.map((stack) => stack.appApiPort), - ); - const gatewayPorts = new Set( - intelligenceDevStacks.map((stack) => stack.gatewayPort), - ); + return sections; +} - expect(projectNames.size).toBe(intelligenceDevStacks.length); - expect(postgresPorts.size).toBe(intelligenceDevStacks.length); - expect(redisPorts.size).toBe(intelligenceDevStacks.length); - expect(appApiPorts.size).toBe(intelligenceDevStacks.length); - expect(gatewayPorts.size).toBe(intelligenceDevStacks.length); +/** Returns the managed Markdown section containing both managed fields. */ +function managedMarkdownSection(markdown: string): string { + return ( + markdownHeadingSections(markdown).find( + ({ heading, contents }) => + MANAGED_DOCUMENTATION_LABEL.test(heading) && + exactEnvIdentifierPattern(MANAGED_API_KEY).test(contents) && + exactEnvIdentifierPattern(OPTIONAL_TELEMETRY_ID).test(contents), + )?.contents ?? "" + ); +} + +/** Returns distinct offline or self-hosted license-token guidance. */ +function offlineOrSelfHostedLicenseSection(markdown: string): string { + return ( + markdownHeadingSections(markdown).find( + ({ heading, contents }) => + SELF_HOSTED_OR_OFFLINE_LABEL.test(heading) && + (exactEnvIdentifierPattern(MANAGED_LICENSE_TOKEN).test(contents) || + /\blicen[cs]e\s+token\b/i.test(contents)), + )?.contents ?? "" + ); +} + +/** Returns whether a Markdown line has a heading ancestor with the label. */ +function markdownLineHasLabeledAncestor( + lines: readonly string[], + lineIndex: number, + label: RegExp, +): boolean { + let childLevel = 7; + for (let index = lineIndex; index >= 0; index -= 1) { + const heading = lines[index]?.match(/^(#{1,6})\s+(.+?)\s*$/); + const headingLevel = heading?.[1]?.length ?? 0; + if (heading && headingLevel < childLevel) { + if (label.test(heading[2] ?? "")) { + return true; + } + childLevel = headingLevel; + } + } + + return false; +} + +/** Asserts every README license mention has a deployment-mode heading. */ +function expectMarkdownLicenseOccurrencesClassified(markdown: string): void { + const lines = markdown.split(/\r?\n/); + const licenseLines = markdownLicenseOccurrenceLines(lines); + + expect( + licenseLines.every((index) => + markdownLineHasLabeledAncestor( + lines, + index, + SELF_HOSTED_OR_OFFLINE_LABEL, + ), + ), + ).toBe(true); +} + +/** + * Asserts the managed runtime reads only the managed project credential. + * + * @param contents - Runtime route or bridge source. + */ +function expectManagedRuntimeContract(contents: string): void { + const sourceFile = parseManagedSource(contents); + + expect(hasRuntimeApiKeyRead(sourceFile)).toBe(true); + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_API_KEY)); + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_TELEMETRY_ID)); +} + +/** + * Asserts the browser-safe feature gate follows the managed project credential. + * + * @param contents - Next.js or Vite gate configuration source. + */ +function expectManagedGateContract(contents: string): void { + const sourceFile = parseManagedSource(contents); + + expect(hasExportedGateApiKeyRead(sourceFile)).toBe(true); + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_API_KEY)); + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_TELEMETRY_ID)); + expect(contents).not.toContain(MANAGED_LICENSE_TOKEN); +} + +/** + * Asserts an env example documents the managed key and optional telemetry ID. + * + * @param contents - Managed template environment example contents. + */ +function expectManagedEnvContract(contents: string): void { + const managedSections = managedEnvSections(contents); + + expect(managedSections).not.toBeNull(); + if (!managedSections) return; + expect(managedSections.apiKey).toMatch(envAssignmentPattern(MANAGED_API_KEY)); + expectTelemetryIdentityDocumentation(managedSections.telemetry); + expect(contents).not.toMatch(envAssignmentPattern(LEGACY_API_KEY)); + expect(contents).not.toMatch(envAssignmentPattern(LEGACY_TELEMETRY_ID)); + expectEnvLicenseOccurrencesClassified(contents); +} + +/** + * Asserts a README documents the managed key and optional telemetry identity. + * + * @param contents - Managed template README contents. + */ +function expectManagedReadmeContract(contents: string): void { + const managedSection = managedMarkdownSection(contents); + const offlineOrSelfHostedSection = + offlineOrSelfHostedLicenseSection(contents); + + expect(managedSection).not.toBe(""); + expect(managedSection).not.toContain(MANAGED_LICENSE_TOKEN); + expectTelemetryIdentityDocumentation(managedSection); + expect(offlineOrSelfHostedSection).not.toBe(""); + if (offlineOrSelfHostedSection) { + expect(managedSection).not.toContain(offlineOrSelfHostedSection); + } + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_API_KEY)); + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_TELEMETRY_ID)); + expectMarkdownLicenseOccurrencesClassified(contents); +} + +/** Reads the Runtime and React pins that own one template's entitlement flow. */ +function readManagedSdkPins( + contract: ManagedTemplateContract, +): readonly ManagedSdkPin[] { + const packageContracts = + contract.directory === "agentcore" + ? [ + { + packageName: "@copilotkit/runtime" as const, + packagePath: "infra-cdk/lambdas/copilotkit-runtime/package.json", + }, + { + packageName: "@copilotkit/react-core" as const, + packagePath: "frontend/package.json", + }, + ] + : [ + { + packageName: "@copilotkit/runtime" as const, + packagePath: "package.json", + }, + { + packageName: "@copilotkit/react-core" as const, + packagePath: "package.json", + }, + ]; + + return packageContracts.map(({ packageName, packagePath }) => { + const manifest = JSON.parse( + readManagedSurface(contract, packagePath, `${packageName} manifest`), + ) as { + readonly dependencies?: Readonly>; + readonly devDependencies?: Readonly>; + }; + const version = + manifest.dependencies?.[packageName] ?? + manifest.devDependencies?.[packageName] ?? + ""; + + expect( + version, + `${contract.directory} must pin ${packageName} in ${packagePath}`, + ).toMatch(/^\d+\.\d+\.\d+$/u); + + return { packageName, packagePath, version }; + }); +} + +/** Returns whether a classified package pin supports managed entitlements. */ +function pinSupportsManagedEntitlements(pin: ManagedSdkPin): boolean { + const classifications = MANAGED_ENTITLEMENT_CONTRACT_BY_SDK_VERSION[ + pin.packageName + ] as Readonly>; + const supportsManagedEntitlements = classifications[pin.version]; + + expect( + supportsManagedEntitlements, + `${pin.packageName}@${pin.version} must be classified before a managed template can use it`, + ).not.toBeUndefined(); + + return supportsManagedEntitlements === true; +} + +/** + * Asserts stale managed SDK pins retain the legacy license-token bridge. + * + * The managed API key remains the Intelligence credential. The compatibility + * token only supplies the entitlement signal expected by older Runtime and + * React releases. + */ +function expectPinnedSdkCompatibilityContract( + pins: readonly ManagedSdkPin[], + runtime: string, + envExample: string, + readme: string, +): void { + const stalePins = pins.filter((pin) => !pinSupportsManagedEntitlements(pin)); + if (stalePins.length === 0) return; + + const sourceFile = parseManagedSource(runtime); + const runtimeOptions = newExpressionOptions(sourceFile, "CopilotRuntime"); + const licenseTokens = runtimeOptions.flatMap((options) => { + const property = objectPropertyAssignment(options, "licenseToken"); + return property ? [property] : []; + }); + expect(licenseTokens).toHaveLength(1); + const licenseToken = licenseTokens[0] ?? null; + expect( + licenseToken && + expressionContainsEnvRead( + licenseToken.initializer, + MANAGED_LICENSE_TOKEN, + ), + ).toBe(true); + + expect( + envExample + .split(/\r?\n/u) + .filter((line) => envAssignmentPattern(MANAGED_LICENSE_TOKEN).test(line)), + ).toHaveLength(1); + + const compatibilitySection = + markdownHeadingSections(readme).find( + ({ heading, contents }) => + /\bpinned SDK compatibility\b/i.test(heading) && + exactEnvIdentifierPattern(MANAGED_LICENSE_TOKEN).test(contents), + )?.contents ?? ""; + expect(compatibilitySection).not.toBe(""); + expect(compatibilitySection).toMatch( + exactEnvIdentifierPattern(MANAGED_API_KEY), + ); + expect(compatibilitySection).toMatch(/\bmanaged entitlement/i); + for (const version of new Set(stalePins.map((pin) => pin.version))) { + expect(compatibilitySection).toContain(version); + } +} + +/** Asserts one ordinary template retains its framework-specific Runtime agent. */ +function expectRuntimeAgentContract( + contents: string, + contract: RuntimeAgentContract, +): void { + const sourceFile = parseManagedSource(contents); + const runtimeOptions = newExpressionOptions(sourceFile, "CopilotRuntime"); + + expect(runtimeOptions).toHaveLength(1); + if (runtimeOptions.length !== 1) return; + const agents = objectPropertyAssignment(runtimeOptions[0]!, "agents"); + expect(agents).not.toBeNull(); + if (!agents) return; + const registeredAgents = unwrapExpression(agents.initializer); + + if (contract.registration === "factory") { + expect(ts.isCallExpression(registeredAgents)).toBe(true); + if (!ts.isCallExpression(registeredAgents)) return; + expect(propertyAccessParts(registeredAgents.expression)).toEqual( + contract.calleePath, + ); + const argument = registeredAgents.arguments[0] + ? unwrapExpression(registeredAgents.arguments[0]) + : null; + expect(argument && ts.isObjectLiteralExpression(argument)).toBe(true); + if (!argument || !ts.isObjectLiteralExpression(argument)) return; + expect( + objectPropertyIsIdentifier( + argument, + contract.argumentIdentifier, + contract.argumentIdentifier, + ), + ).toBe(true); + return; + } + + expect(ts.isObjectLiteralExpression(registeredAgents)).toBe(true); + if (!ts.isObjectLiteralExpression(registeredAgents)) return; + const defaultAgent = objectPropertyAssignment(registeredAgents, "default"); + expect(defaultAgent).not.toBeNull(); + if (!defaultAgent) return; + const agent = registeredAgentExpression(sourceFile, defaultAgent.initializer); + expect(ts.isNewExpression(agent)).toBe(true); + if (!ts.isNewExpression(agent)) return; + expect(propertyAccessParts(agent.expression)).toEqual([ + contract.constructorName, + ]); + const agentOptions = agent.arguments?.[0] + ? unwrapExpression(agent.arguments[0]) + : null; + expect(agentOptions && ts.isObjectLiteralExpression(agentOptions)).toBe(true); + if (!agentOptions || !ts.isObjectLiteralExpression(agentOptions)) return; + + for (const optionContract of contract.options) { + const option = objectPropertyAssignment( + agentOptions, + optionContract.property, + ); + expect(option).not.toBeNull(); + if (!option) continue; + for (const identifier of optionContract.environmentReads ?? []) { + expect(expressionContainsEnvRead(option.initializer, identifier)).toBe( + true, + ); + } + for (const literal of optionContract.stringLiterals ?? []) { + expect(expressionContainsStringLiteral(option.initializer, literal)).toBe( + true, + ); + } + } +} + +/** Asserts a Next/Hono runtime preserves its complete REST handler surface. */ +function expectEndpointHandlerContract(contents: string): void { + const handlers = exportedEndpointHandlers(parseManagedSource(contents)); + + expect([...handlers].sort()).toEqual(["DELETE", "GET", "PATCH", "POST"]); +} + +/** Asserts a frontend preserves REST transport and shared thread ownership. */ +function expectFrontendThreadContract( + providerContents: string, + threadContents: string, +): void { + const providerSource = parseManagedSource(providerContents); + const threadSource = parseManagedSource(threadContents); + const providers = [ + ...jsxNodesWithTag(providerSource, "CopilotKit"), + ...jsxNodesWithTag(providerSource, "CopilotKitProvider"), + ]; + const configurationProviders = jsxNodesWithTag( + threadSource, + "CopilotChatConfigurationProvider", + ); + const drawers = jsxNodesWithTag(threadSource, "CopilotThreadsDrawer"); + + expect(providers).toHaveLength(1); + expect(configurationProviders).toHaveLength(1); + expect(drawers).toHaveLength(1); + if ( + providers.length !== 1 || + configurationProviders.length !== 1 || + drawers.length !== 1 + ) { + return; + } + + const provider = providers[0]!; + const configurationProvider = configurationProviders[0]!; + const drawer = drawers[0]!; + const configuredAgent = jsxAttributeIdentity( + jsxAttribute(configurationProvider, "agentId"), + ); + const drawerAgent = jsxAttributeIdentity(jsxAttribute(drawer, "agentId")); + + expect(jsxAttribute(provider, "runtimeUrl")).not.toBeNull(); + expect(jsxAttributeIsFalse(jsxAttribute(provider, "useSingleEndpoint"))).toBe( + true, + ); + expect(jsxAttribute(configurationProvider, "threadId")).toBeNull(); + expect(configuredAgent).not.toBeNull(); + expect(drawerAgent).toBe(configuredAgent); + expect( + jsxNodesWithTag(configurationProvider, "CopilotThreadsDrawer"), + ).toHaveLength(1); + + const configuredChatCount = [ + "CopilotChat", + "CopilotSidebar", + "Chat", + "ResearchAssistant", + ].reduce( + (count, tagName) => + count + jsxNodesWithTag(configurationProvider, tagName).length, + 0, + ); + expect(configuredChatCount).toBeGreaterThan(0); +} + +/** Asserts MCP Apps retains its client middleware configuration. */ +function expectMcpAppsRuntimeBehavior(contents: string): void { + const sourceFile = parseManagedSource(contents); + const middlewareOptions = newExpressionOptions( + sourceFile, + "MCPAppsMiddleware", + ); + const agentOptions = newExpressionOptions(sourceFile, "BuiltInAgent"); + + expect(middlewareOptions).toHaveLength(1); + expect(agentOptions).toHaveLength(1); + expect(sourceContainsCall(sourceFile, ["agent", "use"])).toBe(true); + if (middlewareOptions.length !== 1) return; + + const serversProperty = objectPropertyAssignment( + middlewareOptions[0]!, + "mcpServers", + ); + const servers = serversProperty + ? unwrapExpression(serversProperty.initializer) + : null; + expect(servers && ts.isArrayLiteralExpression(servers)).toBe(true); + if (!servers || !ts.isArrayLiteralExpression(servers)) return; + + const configuredServers = servers.elements.filter( + ts.isObjectLiteralExpression, + ); + expect(configuredServers).toHaveLength(1); + if (configuredServers.length !== 1) return; + expect(objectPropertyIsString(configuredServers[0]!, "type", "http")).toBe( + true, + ); + expect( + objectPropertyIsString(configuredServers[0]!, "serverId", "threejs"), + ).toBe(true); + expect( + objectPropertyIsString( + configuredServers[0]!, + "url", + "http://localhost:3108/mcp", + ), + ).toBe(true); +} + +/** Asserts the bundled MCP server retains its tools and UI resource. */ +function expectMcpServerBehavior( + serverContents: string, + transportContents: string, +): void { + const serverSource = parseManagedSource(serverContents); + const transportSource = parseManagedSource(transportContents); + const appToolCalls = sourceCalls(serverSource, ["registerAppTool"]); + const toolCalls = sourceCalls(serverSource, ["server", "registerTool"]); + const resourceCalls = sourceCalls(serverSource, ["registerAppResource"]); + const routeCalls = sourceCalls(transportSource, ["app", "all"]); + + expect(appToolCalls).toHaveLength(1); + expect(toolCalls).toHaveLength(1); + expect(resourceCalls).toHaveLength(1); + expect(routeCalls).toHaveLength(1); + expect(appToolCalls[0]?.arguments[1]).toMatchObject({ + text: "show_threejs_scene", + }); + expect(toolCalls[0]?.arguments[0]).toMatchObject({ text: "learn_threejs" }); + expect(sourceContainsCall(serverSource, ["startServer"])).toBe(true); + expect(sourceContainsCall(transportSource, ["server", "connect"])).toBe(true); + expect( + sourceContainsCall(transportSource, ["transport", "handleRequest"]), + ).toBe(true); + expect(routeCalls[0]?.arguments[0]).toMatchObject({ text: "/mcp" }); + + const resourceArgument = resourceCalls[0]?.arguments[1]; + expect(resourceArgument && ts.isIdentifier(resourceArgument)).toBe(true); + expect(serverContents).toContain( + 'const resourceUri = "ui://threejs/mcp-app.html"', + ); +} + +/** Returns whether source declares one class extending the expected base. */ +function sourceContainsClassExtension( + sourceFile: ts.SourceFile, + className: string, + baseName: string, +): boolean { + let found = false; + + /** Visits class declarations for the exact inheritance edge. */ + function visit(node: ts.Node): void { + if (found) return; + if ( + ts.isClassDeclaration(node) && + node.name?.text === className && + node.heritageClauses?.some( + (clause) => + clause.token === ts.SyntaxKind.ExtendsKeyword && + clause.types.some( + (type) => + ts.isIdentifier(type.expression) && + type.expression.text === baseName, + ), + ) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return found; +} + +/** Asserts A2A retains isolated multi-agent routing and URL ownership. */ +function expectA2ARuntimeBehavior(contents: string): void { + const sourceFile = parseManagedSource(contents); + const runtimeOptions = newExpressionOptions( + sourceFile, + "RuntimeA2AMiddlewareAgent", + ).find((options) => objectPropertyAssignment(options, "agentUrls")); + + expect( + sourceContainsClassExtension( + sourceFile, + "RuntimeA2AMiddlewareAgent", + "A2AMiddlewareAgent", + ), + ).toBe(true); + expect(newExpressionOptions(sourceFile, "A2AMiddlewareAgent")).toHaveLength( + 1, + ); + expect( + newExpressionOptions(sourceFile, "HttpAgent").length, + ).toBeGreaterThanOrEqual(2); + expect(sourceContainsCall(sourceFile, ["isolatedAgent", "setMessages"])).toBe( + true, + ); + expect(sourceContainsCall(sourceFile, ["isolatedAgent", "runAgent"])).toBe( + true, + ); + for (const identifier of [ + "RESEARCH_AGENT_URL", + "ANALYSIS_AGENT_URL", + "ORCHESTRATOR_URL", + ]) { + expect(sourceContainsEnvRead(sourceFile, identifier)).toBe(true); + } + + expect(runtimeOptions).toBeDefined(); + if (!runtimeOptions) return; + expect(objectPropertyIsString(runtimeOptions, "agentId", "a2a_chat")).toBe( + true, + ); + const agentUrls = objectPropertyAssignment(runtimeOptions, "agentUrls"); + const orchestrationAgentUrl = objectPropertyAssignment( + runtimeOptions, + "orchestrationAgentUrl", + ); + expect(agentUrls?.initializer.getText()).toBe( + "[researchAgentUrl, analysisAgentUrl]", + ); + expect(orchestrationAgentUrl?.initializer.getText()).toBe("orchestratorUrl"); +} + +/** Asserts A2A visualization stays registered inside the configured chat. */ +function expectA2AVisualizationBehavior(contents: string): void { + const sourceFile = parseManagedSource(contents); + const toolCalls = sourceCalls(sourceFile, ["useFrontendTool"]); + const visualizationTool = toolCalls.find((call) => { + const options = call.arguments[0] + ? unwrapExpression(call.arguments[0]) + : null; + return ( + options && + ts.isObjectLiteralExpression(options) && + objectPropertyIsString(options, "name", "send_message_to_a2a_agent") + ); }); - test("local Intelligence compose files drift only by declared starter ports and project names", () => { - const referenceStack = intelligenceDevStacks[0]; - const remainingStacks = intelligenceDevStacks.slice(1); - const referenceCompose = normalizeIntelligenceComposeForDriftGuard( - readIntegrationFile( - referenceStack.integration, + expect(visualizationTool).toBeDefined(); + expect(jsxNodesWithTag(sourceFile, "MessageToA2A")).toHaveLength(1); + expect(jsxNodesWithTag(sourceFile, "MessageFromA2A")).toHaveLength(1); + expect(jsxNodesWithTag(sourceFile, "CopilotChat")).toHaveLength(1); + expect(jsxNodesWithTag(sourceFile, "CopilotKit")).toHaveLength(0); + expect(jsxNodesWithTag(sourceFile, "CopilotKitProvider")).toHaveLength(0); +} + +/** Asserts AgentCore selects managed Intelligence or its custom local runner. */ +function expectAgentCoreRuntimeBehavior(contents: string): void { + const sourceFile = parseManagedSource(contents); + const endpointCalls = sourceCalls(sourceFile, ["createCopilotEndpoint"]); + const requiredAgentUrl = sourceCalls(sourceFile, ["requireEnv"]).some( + (call) => + call.arguments[0] && + ts.isStringLiteral(call.arguments[0]) && + call.arguments[0].text === "AGENTCORE_AG_UI_URL", + ); + + expect( + sourceContainsClassExtension( + sourceFile, + "AgentCoreRunner", + "InMemoryAgentRunner", + ), + ).toBe(true); + expect(requiredAgentUrl).toBe(true); + expect(newExpressionOptions(sourceFile, "HttpAgent")).toHaveLength(1); + expect(newExpressionOptions(sourceFile, "MCPAppsMiddleware")).toHaveLength(1); + + const runtimeInitializers = variableInitializers(sourceFile, "runtime"); + expect(runtimeInitializers).toHaveLength(1); + const runtimeSelection = runtimeInitializers[0] + ? unwrapExpression(runtimeInitializers[0]) + : null; + expect(runtimeSelection && ts.isConditionalExpression(runtimeSelection)).toBe( + true, + ); + if (!runtimeSelection || !ts.isConditionalExpression(runtimeSelection)) { + return; + } + + expect( + expressionUsesManagedKeyWithoutTelemetry(runtimeSelection.condition), + ).toBe(true); + const managedOptions = constructorOptionsFromExpression( + runtimeSelection.whenTrue, + "CopilotRuntime", + ); + const localOptions = constructorOptionsFromExpression( + runtimeSelection.whenFalse, + "CopilotRuntime", + ); + expect(managedOptions).not.toBeNull(); + expect(localOptions).not.toBeNull(); + if (!managedOptions || !localOptions) return; + + expect( + objectPropertyExpression(managedOptions, "intelligence"), + ).not.toBeNull(); + expect(objectPropertyExpression(managedOptions, "runner")).toBeNull(); + expect(objectPropertyExpression(localOptions, "intelligence")).toBeNull(); + const localRunner = objectPropertyExpression(localOptions, "runner"); + expect(localRunner).not.toBeNull(); + const unwrappedLocalRunner = localRunner + ? unwrapExpression(localRunner) + : null; + expect( + unwrappedLocalRunner && + ts.isNewExpression(unwrappedLocalRunner) && + ts.isIdentifier(unwrappedLocalRunner.expression) && + unwrappedLocalRunner.expression.text === "AgentCoreRunner", + ).toBe(true); + expect(endpointCalls).toHaveLength(1); + const endpointOptions = endpointCalls[0]?.arguments[0]; + const unwrappedOptions = endpointOptions + ? unwrapExpression(endpointOptions) + : null; + expect( + unwrappedOptions && ts.isObjectLiteralExpression(unwrappedOptions), + ).toBe(true); + if (!unwrappedOptions || !ts.isObjectLiteralExpression(unwrappedOptions)) { + return; + } + expect( + objectPropertyIsString(unwrappedOptions, "basePath", "/copilotkit"), + ).toBe(true); +} + +/** Asserts AgentCore local services retain bridge routing and one network. */ +function expectAgentCoreNetworkingBehavior(contents: string): void { + const agent = yamlMappingSection(contents, "agent"); + const bridge = yamlMappingSection(contents, "bridge"); + const frontend = yamlMappingSection(contents, "frontend"); + + expect(agent).toMatch(/networks:\s*\n\s*- agentcore-network/); + expect(bridge).toContain("AGENTCORE_AG_UI_URL=http://agent:8080/invocations"); + expect(bridge).toMatch( + /depends_on:\s*\n\s*agent:\s*\n\s*condition: service_healthy/, + ); + expect(bridge).toMatch(/networks:\s*\n\s*- agentcore-network/); + expect(frontend).toMatch( + /depends_on:\s*\n\s*bridge:\s*\n\s*condition: service_started/, + ); + expect(frontend).toMatch(/networks:\s*\n\s*- agentcore-network/); + expect(contents).toMatch( + /^networks:\s*\n\s*agentcore-network:\s*\n\s*driver: bridge\s*$/m, + ); +} + +/** Asserts an AgentCore deploy script preserves one framework variant. */ +function expectAgentCoreVariantBehavior( + contents: string, + pattern: string, + suffix: string, +): void { + expect(contents).toMatch(new RegExp(`^PATTERN="${pattern}"$`, "m")); + expect(contents).toMatch(new RegExp(`^SUFFIX="${suffix}"$`, "m")); + expect(contents).toMatch(/^CONFIG="\$SCRIPT_DIR\/config\.yaml"$/m); + expect(contents).toMatch( + /npx cdk@latest deploy --all --require-approval never/, + ); + expect(contents).toMatch( + /python3 scripts\/deploy-frontend\.py "\$STACK_NAME"/, + ); +} + +/** Returns the provider and thread-owning frontend surfaces for a template. */ +function frontendBehaviorPaths(contract: ManagedTemplateContract): { + readonly providerPath: string; + readonly threadPath: string; +} { + if (contract.directory === "agentcore") { + const chatPath = "frontend/src/components/chat/CopilotKit/index.tsx"; + return { providerPath: chatPath, threadPath: chatPath }; + } + if (contract.directory === "a2a-middleware") { + return { providerPath: "app/page.tsx", threadPath: "app/page.tsx" }; + } + if (contract.directory === "mcp-apps") { + return { providerPath: "app/layout.tsx", threadPath: "app/page.tsx" }; + } + + return { + providerPath: "src/app/layout.tsx", + threadPath: "src/app/page.tsx", + }; +} + +/** Assert AgentCore local services consume the CLI-managed root env safely. */ +function expectAgentCoreLocalComposeContract(contents: string): void { + const bridge = yamlMappingSection(contents, "bridge"); + const frontend = yamlMappingSection(contents, "frontend"); + + expect(bridge).toMatch(rootManagedEnvFilePattern()); + expect(frontend).toMatch(rootManagedEnvFilePattern()); + expect(frontend).not.toContain(VITE_THREADS_GATE); + expect(frontend).not.toContain(OPTIONAL_TELEMETRY_ID); + expect(bridge).not.toMatch( + new RegExp(`^\\s*-\\s*${INTELLIGENCE_API_URL}=`, "m"), + ); + expect(bridge).not.toMatch( + new RegExp(`^\\s*-\\s*${INTELLIGENCE_GATEWAY_WS_URL}=`, "m"), + ); + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_API_KEY)); + expect(contents).not.toMatch(exactEnvIdentifierPattern(LEGACY_TELEMETRY_ID)); + expect(contents).not.toContain(MANAGED_LICENSE_TOKEN); +} + +/** Assert AgentCore deploy configuration carries only a secret reference. */ +function expectAgentCoreDeploymentConfigContract(contents: string): void { + const configuredSecretNames = contents.split(/\r?\n/).flatMap((line) => { + const match = line.match( + new RegExp( + `^${MANAGED_API_KEY_SECRET_CONFIG}\\s*:\\s*([^#\\s][^#]*?)\\s*(?:#.*)?$`, + ), + ); + return match?.[1] ? [match[1].trim()] : []; + }); + + expect(configuredSecretNames).toHaveLength(1); + expect(configuredSecretNames[0]).not.toMatch(/^\$\{|^process\.env\b/); + expect(contents).not.toMatch(exactEnvIdentifierPattern(MANAGED_API_KEY)); +} + +/** Assert AgentCore's deployed Lambda resolves the managed key from a secret. */ +function expectAgentCoreRuntimeDeploymentContract(contents: string): void { + const sourceFile = parseManagedSource(contents); + const lambdaOptions = constructOptions( + sourceFile, + ["lambda", "Function"], + "CopilotKitRuntimeLambda", + ); + expect(lambdaOptions).toHaveLength(1); + const environment = lambdaOptions[0] + ? objectPropertyAssignment(lambdaOptions[0], "environment") + : null; + const environmentObject = environment + ? unwrapExpression(environment.initializer) + : null; + expect( + environmentObject && ts.isObjectLiteralExpression(environmentObject), + ).toBe(true); + const managedKey = + environmentObject && ts.isObjectLiteralExpression(environmentObject) + ? objectPropertyAssignment(environmentObject, MANAGED_API_KEY) + : null; + const telemetryId = + environmentObject && ts.isObjectLiteralExpression(environmentObject) + ? objectPropertyAssignment(environmentObject, OPTIONAL_TELEMETRY_ID) + : null; + const apiUrl = + environmentObject && ts.isObjectLiteralExpression(environmentObject) + ? objectPropertyAssignment(environmentObject, INTELLIGENCE_API_URL) + : null; + const gatewayWsUrl = + environmentObject && ts.isObjectLiteralExpression(environmentObject) + ? objectPropertyAssignment(environmentObject, INTELLIGENCE_GATEWAY_WS_URL) + : null; + expect(managedKey).not.toBeNull(); + expect(telemetryId).not.toBeNull(); + expect(apiUrl).not.toBeNull(); + expect(gatewayWsUrl).not.toBeNull(); + if (!managedKey || !telemetryId || !apiUrl || !gatewayWsUrl) return; + + expect( + expressionContainsPropertyPath(managedKey.initializer, [ + "config", + MANAGED_API_KEY_SECRET_CONFIG, + ]), + ).toBe(true); + expect(expressionContainsSecretResolution(managedKey.initializer)).toBe(true); + expect( + expressionContainsEnvRead( + managedKey.initializer, + MANAGED_API_KEY_SECRET_VERSION_ID, + ), + ).toBe(true); + expect( + expressionContainsEnvRead(managedKey.initializer, MANAGED_API_KEY), + ).toBe(false); + expect( + expressionContainsEnvRead(telemetryId.initializer, OPTIONAL_TELEMETRY_ID), + ).toBe(true); + expect(expressionContainsSecretResolution(telemetryId.initializer)).toBe( + false, + ); + expect( + expressionContainsEnvRead(apiUrl.initializer, INTELLIGENCE_API_URL), + ).toBe(true); + expect( + expressionContainsEnvRead( + gatewayWsUrl.initializer, + INTELLIGENCE_GATEWAY_WS_URL, + ), + ).toBe(true); +} + +/** Assert AgentCore securely projects its legacy SDK compatibility token. */ +function expectAgentCoreLegacyLicenseDeploymentContract( + deploymentConfig: string, + runtimeDeployment: string, + deployScripts: readonly string[], +): void { + expect(deploymentConfig).toMatch( + new RegExp(`^${LEGACY_LICENSE_TOKEN_SECRET_CONFIG}:\\s*[^#\\s][^#]*$`, "m"), + ); + + const sourceFile = parseManagedSource(runtimeDeployment); + const lambdaOptions = constructOptions( + sourceFile, + ["lambda", "Function"], + "CopilotKitRuntimeLambda", + ); + expect(lambdaOptions).toHaveLength(1); + const environment = lambdaOptions[0] + ? objectPropertyAssignment(lambdaOptions[0], "environment") + : null; + const environmentObject = environment + ? unwrapExpression(environment.initializer) + : null; + expect( + environmentObject && ts.isObjectLiteralExpression(environmentObject), + ).toBe(true); + const licenseToken = + environmentObject && ts.isObjectLiteralExpression(environmentObject) + ? objectPropertyAssignment(environmentObject, MANAGED_LICENSE_TOKEN) + : null; + expect(licenseToken).not.toBeNull(); + if (!licenseToken) return; + expect( + expressionContainsPropertyPath(licenseToken.initializer, [ + "config", + LEGACY_LICENSE_TOKEN_SECRET_CONFIG, + ]), + ).toBe(true); + expect(expressionContainsSecretResolution(licenseToken.initializer)).toBe( + true, + ); + expect( + expressionContainsEnvRead( + licenseToken.initializer, + LEGACY_LICENSE_TOKEN_SECRET_VERSION_ID, + ), + ).toBe(true); + expect( + expressionContainsEnvRead(licenseToken.initializer, MANAGED_LICENSE_TOKEN), + ).toBe(false); + + for (const contents of deployScripts) { + const normalized = contents.replace(/\\\r?\n\s*/g, " "); + const lines = normalized.split(/\r?\n/); + const rootEnvLoadIndex = lines.findIndex((line) => + /(?:^|\s)(?:source|\.)\s+["']?\$\{?SCRIPT_DIR\}?\/\.env["']?(?:\s|$)/.test( + line, + ), + ); + const cdkDeployIndex = lines.findIndex((line) => + /npx\s+cdk(?:@\S+)?\s+deploy\b/.test(line), + ); + const tokenSecretConfigIndex = lines.findIndex((line) => + new RegExp( + `^\\s*[A-Z][A-Z0-9_]*=.*${LEGACY_LICENSE_TOKEN_SECRET_CONFIG}`, + ).test(line), + ); + const tokenSecretCommands = lines.flatMap((line, index) => + line.includes(`$${MANAGED_LICENSE_TOKEN}`) && + /aws\s+secretsmanager\s+(?:create-secret|put-secret-value)\b/.test(line) + ? [{ index, line }] + : [], + ); + const tokenVersionExportIndex = lines.findIndex((line) => + new RegExp( + `^\\s*export\\s+${LEGACY_LICENSE_TOKEN_SECRET_VERSION_ID}(?:=|\\s|$)`, + ).test(line), + ); + const tokenUnsetIndices = lines.flatMap((line, index) => + new RegExp(`^\\s*unset\\s+${MANAGED_LICENSE_TOKEN}\\s*$`).test(line) + ? [index] + : [], + ); + + expect(rootEnvLoadIndex).toBeGreaterThanOrEqual(0); + expect(contents).toMatch( + new RegExp(`:\\s+["']\\$\\{${MANAGED_LICENSE_TOKEN}:\\?[^}]+\\}["']`), + ); + expect(tokenSecretConfigIndex).toBeGreaterThan(rootEnvLoadIndex); + expect(tokenSecretCommands).toHaveLength(2); + expect(tokenVersionExportIndex).toBeGreaterThan( + tokenSecretCommands.at(-1)?.index ?? -1, + ); + expect(cdkDeployIndex).toBeGreaterThan(tokenVersionExportIndex); + expect( + tokenUnsetIndices.some( + (index) => + index > (tokenSecretCommands.at(-1)?.index ?? -1) && + index < cdkDeployIndex, + ), + ).toBe(true); + for (const { line } of tokenSecretCommands) { + expect(line).toMatch( + /--secret-string(?:=|\s+)file:\/\/\/dev\/stdin(?:\s|$)/, + ); + expect(line).not.toMatch( + new RegExp( + `--secret-string(?:=|\\s+)["']?\\$\\{?${MANAGED_LICENSE_TOKEN}\\}?["']?(?:\\s|$)`, + ), + ); + } + } +} + +/** Assert Terraform clearly excludes the managed Intelligence credential path. */ +function expectAgentCoreTerraformExclusionContract( + runtimeDeployment: string, + readme: string, +): void { + expect(runtimeDeployment).not.toMatch( + exactEnvIdentifierPattern(MANAGED_API_KEY), + ); + expect(runtimeDeployment).not.toMatch( + exactEnvIdentifierPattern(OPTIONAL_TELEMETRY_ID), + ); + expect(readme).toMatch(/does not project managed Intelligence credentials/i); + expect(readme).toMatch(/use the CDK deployment path/i); +} + +/** Assert an AgentCore variant deploy script safely materializes its key secret. */ +function expectAgentCoreDeployScriptContract(contents: string): void { + const normalized = contents.replace(/\\\r?\n\s*/g, " "); + const lines = normalized.split(/\r?\n/); + const rootEnvLoadIndex = lines.findIndex((line) => + /(?:^|\s)(?:source|\.)\s+["']?\$\{?SCRIPT_DIR\}?\/\.env["']?(?:\s|$)/.test( + line, + ), + ); + const secretConfigAssignment = lines + .map((line, index) => ({ + index, + match: line.match( + new RegExp(`^\\s*([A-Z][A-Z0-9_]*)=.*${MANAGED_API_KEY_SECRET_CONFIG}`), + ), + })) + .find(({ match }) => match?.[1]); + const secretNameVariable = secretConfigAssignment?.match?.[1]; + const secretCommands = lines + .map((line, index) => ({ index, line })) + .filter( + ({ line }) => + line.includes(`$${MANAGED_API_KEY}`) && + /aws\s+secretsmanager\s+(?:create-secret|put-secret-value)\b/.test( + line, + ), + ); + const cdkDeployIndex = lines.findIndex((line) => + /npx\s+cdk(?:@\S+)?\s+deploy\b/.test(line), + ); + const skipBackendGuardIndex = lines.findIndex((line) => + /if\s+\[\s+"\$SKIP_BACKEND"\s+=\s+true\s+\];\s+then/.test(line), + ); + const backendDeployElseIndex = lines.findIndex( + (line, index) => index > skipBackendGuardIndex && line.trim() === "else", + ); + const backendDeployEndIndex = lines.findIndex( + (line, index) => index > cdkDeployIndex && line.trim() === "fi", + ); + const telemetryExportIndex = lines.findIndex((line) => + new RegExp(`^\\s*export\\s+${OPTIONAL_TELEMETRY_ID}(?:=|\\s|$)`).test(line), + ); + const disableAllexportIndex = lines.findIndex((line) => + /^\s*set\s+\+a\s*$/.test(line), + ); + const disableXtraceIndex = lines.findIndex((line) => + /^\s*set\s+\+x\s*$/.test(line), + ); + const managedKeyUnexportIndices = lines.flatMap((line, index) => + new RegExp(`^\\s*export\\s+-n\\s+${MANAGED_API_KEY}\\s*$`).test(line) + ? [index] + : [], + ); + const managedKeyUnsetIndices = lines.flatMap((line, index) => + new RegExp(`^\\s*unset\\s+${MANAGED_API_KEY}\\s*$`).test(line) + ? [index] + : [], + ); + const secretVersionExportIndex = lines.findIndex((line) => + new RegExp( + `^\\s*export\\s+${MANAGED_API_KEY_SECRET_VERSION_ID}(?:=|\\s|$)`, + ).test(line), + ); + + expect(rootEnvLoadIndex).toBeGreaterThanOrEqual(0); + expect(secretNameVariable).toBeDefined(); + expect(secretCommands.length).toBeGreaterThan(0); + if ( + secretConfigAssignment === undefined || + secretNameVariable === undefined || + secretCommands[0] === undefined + ) { + throw new Error("AgentCore secret materialization contract is incomplete"); + } + expect(skipBackendGuardIndex).toBeGreaterThanOrEqual(0); + expect(disableAllexportIndex).toBeGreaterThanOrEqual(0); + expect(disableAllexportIndex).toBeLessThan(rootEnvLoadIndex); + expect(disableXtraceIndex).toBeGreaterThanOrEqual(0); + expect(disableXtraceIndex).toBeLessThan(rootEnvLoadIndex); + expect(contents).not.toMatch(/^\s*set\s+-a\s*$/m); + expect( + managedKeyUnexportIndices.some((index) => index < rootEnvLoadIndex), + ).toBe(true); + expect( + managedKeyUnexportIndices.some( + (index) => index > rootEnvLoadIndex && index < skipBackendGuardIndex, + ), + ).toBe(true); + expect(backendDeployElseIndex).toBeGreaterThan(skipBackendGuardIndex); + expect(secretConfigAssignment.index).toBeGreaterThan(backendDeployElseIndex); + expect(secretVersionExportIndex).toBeGreaterThan(backendDeployElseIndex); + expect(backendDeployEndIndex).toBeGreaterThan(cdkDeployIndex); + expect(telemetryExportIndex).toBeGreaterThan(rootEnvLoadIndex); + expect(cdkDeployIndex).toBeGreaterThan( + Math.max(telemetryExportIndex, ...secretCommands.map(({ index }) => index)), + ); + for (const { line } of secretCommands) { + expect(line).toMatch( + new RegExp( + `printf\\s+['"]%s['"]\\s+["']\\$\\{?${MANAGED_API_KEY}\\}?["']\\s*\\|\\s*aws\\s+secretsmanager`, + ), + ); + expect(line).toMatch( + /--secret-string(?:=|\s+)file:\/\/\/dev\/stdin(?:\s|$)/, + ); + expect(line).not.toMatch( + new RegExp( + `--secret-string(?:=|\\s+)["']?\\$\\{?${MANAGED_API_KEY}\\}?["']?(?:\\s|$)`, + ), + ); + expect(line).toMatch( + new RegExp( + `--(?:name|secret-id)(?:=|\\s+)["']?\\$\\{?${secretNameVariable}\\}?["']?(?:\\s|$)`, + ), + ); + } + expect(rootEnvLoadIndex).toBeLessThan(secretCommands[0].index); + expect(secretConfigAssignment.index).toBeLessThan(secretCommands[0].index); + expect( + managedKeyUnsetIndices.some( + (index) => + index > skipBackendGuardIndex && index < backendDeployElseIndex, + ), + ).toBe(true); + expect( + managedKeyUnsetIndices.some( + (index) => index > secretCommands[0].index && index < cdkDeployIndex, + ), + ).toBe(true); + for (const backendOwnedIndex of [ + secretConfigAssignment.index, + ...secretCommands.map(({ index: commandIndex }) => commandIndex), + secretVersionExportIndex, + cdkDeployIndex, + ]) { + expect(backendOwnedIndex).toBeGreaterThan(backendDeployElseIndex); + expect(backendOwnedIndex).toBeLessThan(backendDeployEndIndex); + } +} + +/** Returns exact path filters for one top-level workflow event. */ +function workflowEventPaths(contents: string, eventName: string): string[] { + const lines = contents.split(/\r?\n/); + const eventStart = lines.findIndex((line) => line === ` ${eventName}:`); + if (eventStart < 0) return []; + const relativeEventEnd = lines + .slice(eventStart + 1) + .findIndex((line) => /^(?:\S| {2}\S).*:\s*$/.test(line)); + const eventEnd = + relativeEventEnd < 0 ? lines.length : eventStart + relativeEventEnd + 1; + const eventLines = lines.slice(eventStart, eventEnd); + const pathsStart = eventLines.findIndex((line) => line === " paths:"); + if (pathsStart < 0) return []; + + const paths: string[] = []; + for (const line of eventLines.slice(pathsStart + 1)) { + const match = line.match(/^ - ["']?(.+?)["']?\s*$/); + if (!match?.[1]) break; + paths.push(match[1]); + } + return paths; +} + +interface WorkflowStep { + readonly name: string; + readonly run: string; +} + +/** Returns named workflow steps with their complete run scripts. */ +function workflowSteps(contents: string): WorkflowStep[] { + const lines = contents.split(/\r?\n/); + const stepStarts = lines.flatMap((line, index) => + /^ - name:\s*/.test(line) ? [index] : [], + ); + + return stepStarts.map((start, position) => { + const end = stepStarts[position + 1] ?? lines.length; + const stepLines = lines.slice(start, end); + const name = stepLines[0]! + .replace(/^ - name:\s*/, "") + .replace(/^["']|["']$/g, ""); + const runStart = stepLines.findIndex((line) => + line.startsWith(" run:"), + ); + const run = + runStart < 0 + ? "" + : stepLines + .slice(runStart) + .join("\n") + .replace(/^\s*run:\s*[|>-]?\s*/m, "") + .trim(); + return { name, run }; + }); +} + +/** Assert the parity workflow preserves filters and runs red contracts second. */ +function expectIntegrationParityWorkflowContract(contents: string): void { + const expectedPaths = [ + "examples/integrations/**", + "scripts/__tests__/integration-intelligence-migration.test.ts", + ".github/workflows/integrations_parity.yml", + ]; + expect(workflowEventPaths(contents, "pull_request")).toEqual(expectedPaths); + expect(workflowEventPaths(contents, "push")).toEqual(expectedPaths); + + const steps = workflowSteps(contents); + const parityIndex = steps.findIndex( + ({ name }) => name === "Verify integration-demo parity", + ); + const contractIndex = steps.findIndex( + ({ name }) => name === "Verify Intelligence template credential contracts", + ); + expect(parityIndex).toBeGreaterThanOrEqual(0); + expect(contractIndex).toBeGreaterThan(parityIndex); + expect(steps[parityIndex]?.run).toContain("pnpm parity:check"); + expect(steps[contractIndex]?.run).toContain( + "pnpm exec vitest run scripts/__tests__/integration-intelligence-migration.test.ts", + ); +} + +/** Renders an exact workflow path-filter list for helper self-tests. */ +function renderWorkflowPaths(values: readonly string[]): string { + return values.map((value) => ` - "${value}"`).join("\n"); +} + +for (const stack of INTELLIGENCE_DEV_STACKS) { + test(`${stack.integration} has a valid local Intelligence compose stack`, () => { + const compose = readIntelligenceDevSurface( + stack.integration, + "docker-compose.intelligence.yml", + ); + const postgresInit = readIntelligenceDevSurface( + stack.integration, + "docker/postgres-init/01-create-databases.sql", + ); + const readme = readIntelligenceDevSurface(stack.integration, "README.md"); + const envExample = readOptionalIntelligenceDevSurface( + stack.integration, + ".env.example", + ); + + expectDockerComposeConfigToSucceed(stack.integration); + expect(compose).toContain(`name: ${stack.projectName}`); + expect(compose).toContain( + `\${POSTGRES_HOST_PORT:-${stack.postgresPort}}:5432`, + ); + expect(compose).toContain(`\${REDIS_HOST_PORT:-${stack.redisPort}}:6379`); + expect(compose).toContain( + `\${APP_API_HOST_PORT:-${stack.appApiPort}}:4201`, + ); + expect(compose).toContain( + `\${GATEWAY_HOST_PORT:-${stack.gatewayPort}}:4401`, + ); + expect(compose).toContain( + "./docker/postgres-init:/docker-entrypoint-initdb.d:ro", + ); + expect(compose).not.toContain("./docker:/docker-entrypoint-initdb.d"); + expect(compose).toContain("pg_isready -U intelligence -d intelligence_app"); + expect(postgresInit).toBe(EXPECTED_POSTGRES_INIT_SQL); + expect(readme).toContain("## CopilotKit Intelligence & Threads (Optional)"); + expect(readme).toContain("docker-compose.intelligence.yml"); + expect(readme).toContain( + `INTELLIGENCE_API_URL=http://localhost:${stack.appApiPort}`, + ); + expect(readme).toContain( + `INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:${stack.gatewayPort}`, + ); + + if (envExample) { + expect(envExample).toContain( + `INTELLIGENCE_API_URL=http://localhost:${stack.appApiPort}`, + ); + expect(envExample).toContain( + `INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:${stack.gatewayPort}`, + ); + } + }); +} + +test("local Intelligence compose host defaults do not collide", () => { + const projectNames = new Set( + INTELLIGENCE_DEV_STACKS.map((stack) => stack.projectName), + ); + const postgresPorts = new Set( + INTELLIGENCE_DEV_STACKS.map((stack) => stack.postgresPort), + ); + const redisPorts = new Set( + INTELLIGENCE_DEV_STACKS.map((stack) => stack.redisPort), + ); + const appApiPorts = new Set( + INTELLIGENCE_DEV_STACKS.map((stack) => stack.appApiPort), + ); + const gatewayPorts = new Set( + INTELLIGENCE_DEV_STACKS.map((stack) => stack.gatewayPort), + ); + + expect(projectNames.size).toBe(INTELLIGENCE_DEV_STACKS.length); + expect(postgresPorts.size).toBe(INTELLIGENCE_DEV_STACKS.length); + expect(redisPorts.size).toBe(INTELLIGENCE_DEV_STACKS.length); + expect(appApiPorts.size).toBe(INTELLIGENCE_DEV_STACKS.length); + expect(gatewayPorts.size).toBe(INTELLIGENCE_DEV_STACKS.length); +}); + +test("local Intelligence compose files drift only by declared starter ports and project names", () => { + const referenceStack = INTELLIGENCE_DEV_STACKS[0]; + const remainingStacks = INTELLIGENCE_DEV_STACKS.slice(1); + const referenceCompose = normalizeIntelligenceComposeForDriftGuard( + readIntelligenceDevSurface( + referenceStack.integration, + "docker-compose.intelligence.yml", + ), + ); + + for (const stack of remainingStacks) { + const compose = normalizeIntelligenceComposeForDriftGuard( + readIntelligenceDevSurface( + stack.integration, "docker-compose.intelligence.yml", ), ); - for (const stack of remainingStacks) { - const compose = normalizeIntelligenceComposeForDriftGuard( - readIntegrationFile( - stack.integration, - "docker-compose.intelligence.yml", - ), - ); - - expect(compose).toBe(referenceCompose); - } - }); -}); - -describe("batch-2 Intelligence integration migration", () => { - for (const integration of migratedIntegrations) { - it(`${integration} has the env-gated Intelligence runtime route`, () => { - const route = readIntegrationFile( - integration, - `${appRoots[integration]}/api/copilotkit/[[...slug]]/route.ts`, - ); - - expect(route).toContain("CopilotKitIntelligence"); - expect(route).toContain("process.env.COPILOTKIT_LICENSE_TOKEN"); - expect(route).toContain( - "licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN", - ); - expect(route).toContain('id: "demo-user"'); - expect(route).toContain("new InMemoryAgentRunner()"); - expect(route).toContain("export const GET = handle(app)"); - expect(route).toContain("export const POST = handle(app)"); - expect(route).toContain("export const PATCH = handle(app)"); - expect(route).toContain("export const DELETE = handle(app)"); - }); - - it(`${integration} forces REST transport for thread routes`, () => { - const layout = readIntegrationFile( - integration, - `${appRoots[integration]}/layout.tsx`, - ); - const page = readIntegrationFile( - integration, - `${appRoots[integration]}/page.tsx`, - ); - - expect(`${layout}\n${page}`).toContain("useSingleEndpoint={false}"); - }); - - it(`${integration} wires the threads drawer into the chat thread context`, () => { - const page = readIntegrationFile( - integration, - `${appRoots[integration]}/page.tsx`, - ); - - expect(page).toContain("ThreadsDrawer"); - expect(page).toContain("ThreadsPanelGate"); - expect(page).toContain("CopilotChatConfigurationProvider"); - expect(page).toContain("threadId"); - expect(page).toContain("onThreadChange={setThreadId}"); - - if (integration === "mcp-apps") { - expect(page).toContain('key={threadId ?? "new-thread"}'); - expect(page).toContain("threadId={threadId}"); - - const drawer = readIntegrationFile( - integration, - "app/components/threads-drawer/threads-drawer.tsx", - ); - expect(drawer).toContain("onThreadChange(undefined)"); - expect(drawer).not.toContain("crypto.randomUUID()"); - } - }); - - it(`${integration} exposes the client-safe threads enabled gate`, () => { - const nextConfig = readIntegrationFile(integration, "next.config.ts"); - - expect(nextConfig).toContain("NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED"); - expect(nextConfig).toContain("process.env.COPILOTKIT_LICENSE_TOKEN"); - }); - - it(`${integration} documents the local Intelligence environment`, () => { - const envExample = readOptionalIntegrationFile( - integration, - ".env.example", - ); - - expect(envExample).toContain("COPILOTKIT_LICENSE_TOKEN"); - expect(envExample).toContain("INTELLIGENCE_API_KEY"); - expect(envExample).toContain("INTELLIGENCE_API_URL"); - expect(envExample).toContain("INTELLIGENCE_GATEWAY_WS_URL"); - }); - - it(`${integration} pins CopilotKit packages to the threads-capable release`, () => { - const packageJson = JSON.parse( - readIntegrationFile(integration, "package.json"), - ) as { dependencies?: Record }; - - expect(packageJson.dependencies?.["@copilotkit/react-core"]).toBe( - "1.59.3", - ); - expect(packageJson.dependencies?.["@copilotkit/runtime"]).toBe("1.59.3"); - }); + expect(compose).toBe(referenceCompose); } }); -test("a2a-middleware runtime route is gated for Intelligence threads", () => { - const route = readA2AMiddlewareFile( - "app/api/copilotkit/[[...slug]]/route.ts", +test("managed TypeScript helpers reject API-key identifiers outside configured expressions", () => { + const unusedRuntimeRead = ` + const unused = process.env.CPK_INTELLIGENCE_API_KEY; + // apiKey: process.env.CPK_INTELLIGENCE_API_KEY + const intelligence = { apiKey: "CPK_INTELLIGENCE_API_KEY" }; + `; + const unusedGateRead = ` + const unused = process.env["CPK_INTELLIGENCE_API_KEY"]; + // NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.CPK_INTELLIGENCE_API_KEY + export default { + env: { NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: "false" }, + }; + `; + + expect(() => expectManagedRuntimeContract(unusedRuntimeRead)).toThrow(); + expect(() => expectManagedGateContract(unusedGateRead)).toThrow(); +}); + +test("managed TypeScript helpers reject decoy configured properties", () => { + const decoyRuntimeRead = ` + const decoy = { apiKey: process.env.CPK_INTELLIGENCE_API_KEY }; + const intelligence = new CopilotKitIntelligence({ apiKey: "" }); + `; + const decoyNextGateRead = ` + const decoy = { + NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: + process.env.CPK_INTELLIGENCE_API_KEY ? "true" : "false", + }; + const nextConfig = { + env: { NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: "false" }, + }; + export default nextConfig; + `; + expect(() => expectManagedRuntimeContract(decoyRuntimeRead)).toThrow(); + expect(() => expectManagedGateContract(decoyNextGateRead)).toThrow(); +}); + +test.each([ + { + name: "inline client", + source: ` + const runtime = new CopilotRuntime({ + agents: {}, + intelligence: new CopilotKitIntelligence({ + apiKey: process.env.CPK_INTELLIGENCE_API_KEY, + }), + }); + `, + }, + { + name: "bound client", + source: ` + const intelligence = new CopilotKitIntelligence({ + apiKey: process.env.CPK_INTELLIGENCE_API_KEY, + }); + const runtime = new CopilotRuntime({ agents: {}, intelligence }); + `, + }, +])( + "managed Runtime helper accepts a $name wired into the Runtime", + ({ source }) => { + expect(() => expectManagedRuntimeContract(source)).not.toThrow(); + }, +); + +test("managed Runtime helper rejects a configured Intelligence client that is not wired into the Runtime", () => { + const unusedConfiguredClient = ` + const intelligence = new CopilotKitIntelligence({ + apiKey: process.env.CPK_INTELLIGENCE_API_KEY, + }); + const runtime = new CopilotRuntime({ + agents: { default: new HttpAgent({ url: "http://localhost:8000" }) }, + }); + `; + + expect(() => expectManagedRuntimeContract(unusedConfiguredClient)).toThrow(); +}); + +test("managed gate helpers reject direct, concatenated, comment-only, and decoy key exposure", () => { + const invalidGateSources = [ + `export default { env: { ${NEXT_THREADS_GATE}: process.env.${MANAGED_API_KEY} } };`, + `export default { env: { ${NEXT_THREADS_GATE}: "enabled-" + process.env.${MANAGED_API_KEY} } };`, + ` + // ${NEXT_THREADS_GATE}: process.env.${MANAGED_API_KEY} ? "true" : "false" + export default { env: { ${NEXT_THREADS_GATE}: "false" } }; + `, + ` + const decoy = process.env.${MANAGED_API_KEY} ? "true" : "false"; + export default { env: { ${NEXT_THREADS_GATE}: "false" } }; + `, + ]; + + for (const source of invalidGateSources) { + expect(() => expectManagedGateContract(source)).toThrow(); + } +}); + +test("managed gate helpers accept a normalized Next boolean projection", () => { + const nextGate = ` + export default { + env: { + ${NEXT_THREADS_GATE}: process.env.${MANAGED_API_KEY} + ? "true" + : "false", + }, + }; + `; + + expect(() => expectManagedGateContract(nextGate)).not.toThrow(); +}); + +test("AgentCore deployment does not forward the unused client entitlement marker", () => { + const contract = INTELLIGENCE_TEMPLATE_CONTRACTS.find( + ({ directory }) => directory === "agentcore", + ); + if (!contract || !("supportedPaths" in contract)) { + throw new Error("AgentCore managed template contract is missing"); + } + + const frontendDeployment = readManagedSurface( + contract, + contract.supportedPaths.frontendDeploymentPath, + "frontend deployment config", + ); + const deployScripts = contract.supportedPaths.deployScriptPaths.map( + (deployScriptPath) => + readManagedSurface(contract, deployScriptPath, "deploy script"), ); - expect(route).toContain("CopilotKitIntelligence"); - expect(route).toContain( - "class RuntimeA2AMiddlewareAgent extends A2AMiddlewareAgent", + expect(frontendDeployment).not.toMatch( + exactEnvIdentifierPattern(VITE_THREADS_GATE), ); - expect(route).toContain("const isolatedAgent = new A2AMiddlewareAgent"); - expect(route).toContain("new HttpAgent({"); - expect(route).toContain("isolatedAgent.setMessages(parameters.messages)"); - expect(route).toContain("return isolatedAgent.runAgent("); - expect(route).toContain("process.env.COPILOTKIT_LICENSE_TOKEN"); - expect(route).toContain("process.env.INTELLIGENCE_API_KEY"); - expect(route).toContain("process.env.INTELLIGENCE_API_URL"); - expect(route).toContain("process.env.INTELLIGENCE_GATEWAY_WS_URL"); - expect(route).toContain('id: "demo-user"'); - expect(route).toContain("licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN"); - expect(route).toContain(": { runner: new InMemoryAgentRunner() }"); - expect(route).toContain("export const GET = handle(app);"); - expect(route).toContain("export const POST = handle(app);"); - expect(route).toContain("export const PATCH = handle(app);"); - expect(route).toContain("export const DELETE = handle(app);"); + for (const deployScript of deployScripts) { + expect(deployScript).not.toMatch( + exactEnvIdentifierPattern(VITE_THREADS_GATE), + ); + } }); -test("a2a-middleware preserves its three-agent URL configuration", () => { - const route = readA2AMiddlewareFile( - "app/api/copilotkit/[[...slug]]/route.ts", +test("AgentCore structural helpers accept linked root-env, secret, and Lambda wiring", () => { + const deploymentConfig = `${MANAGED_API_KEY_SECRET_CONFIG}: cpk-managed-key`; + const runtimeDeployment = ` + new lambda.Function(this, "CopilotKitRuntimeLambda", { + environment: { + ${MANAGED_API_KEY}: cdk.SecretValue.secretsManager( + config.${MANAGED_API_KEY_SECRET_CONFIG}, + { versionId: process.env.${MANAGED_API_KEY_SECRET_VERSION_ID} }, + ).unsafeUnwrap(), + ${OPTIONAL_TELEMETRY_ID}: process.env.${OPTIONAL_TELEMETRY_ID} ?? "", + ${INTELLIGENCE_API_URL}: process.env.${INTELLIGENCE_API_URL} ?? "http://localhost:4201", + ${INTELLIGENCE_GATEWAY_WS_URL}: process.env.${INTELLIGENCE_GATEWAY_WS_URL} ?? "ws://localhost:4401", + }, + }); + `; + const deployScript = ` + set +a + set +x + export -n ${MANAGED_API_KEY} + source "$SCRIPT_DIR/.env" + export -n ${MANAGED_API_KEY} + export ${OPTIONAL_TELEMETRY_ID} + if [ "$SKIP_BACKEND" = true ]; then + unset ${MANAGED_API_KEY} + echo "Skipping backend" + else + CPK_SECRET_NAME=$(read_config ${MANAGED_API_KEY_SECRET_CONFIG}) + printf '%s' "$${MANAGED_API_KEY}" | aws secretsmanager create-secret --name "$CPK_SECRET_NAME" --secret-string file:///dev/stdin + unset ${MANAGED_API_KEY} + export ${MANAGED_API_KEY_SECRET_VERSION_ID} + npx cdk deploy --all + fi + `; + + expect(() => + expectAgentCoreDeploymentConfigContract(deploymentConfig), + ).not.toThrow(); + expect(() => + expectAgentCoreRuntimeDeploymentContract(runtimeDeployment), + ).not.toThrow(); + expect(() => expectAgentCoreDeployScriptContract(deployScript)).not.toThrow(); +}); + +test("AgentCore structural helpers reject comment, unrelated-secret, and disconnected-command decoys", () => { + const runtimeDecoy = ` + // ${MANAGED_API_KEY} uses config.${MANAGED_API_KEY_SECRET_CONFIG} + const unrelated = cdk.SecretValue.secretsManager("other-secret"); + new lambda.Function(this, "CopilotKitRuntimeLambda", { + environment: { + ${MANAGED_API_KEY}: "literal-key", + ${OPTIONAL_TELEMETRY_ID}: "literal-telemetry-id", + }, + }); + `; + const runtimeTelemetrySecretDecoy = ` + new lambda.Function(this, "CopilotKitRuntimeLambda", { + environment: { + ${MANAGED_API_KEY}: cdk.SecretValue.secretsManager( + config.${MANAGED_API_KEY_SECRET_CONFIG}, + ).unsafeUnwrap(), + ${OPTIONAL_TELEMETRY_ID}: cdk.SecretValue.secretsManager( + config.${MANAGED_API_KEY_SECRET_CONFIG}, + ).unsafeUnwrap(), + }, + }); + `; + const deployScriptDecoy = ` + # source "$SCRIPT_DIR/.env" + CPK_SECRET_NAME=$(read_config ${MANAGED_API_KEY_SECRET_CONFIG}) + echo "$${MANAGED_API_KEY}" + aws secretsmanager create-secret --name unrelated --secret-string literal + npx cdk deploy --all + export ${OPTIONAL_TELEMETRY_ID} + `; + const lateTelemetryExportDecoy = ` + source "$SCRIPT_DIR/.env" + CPK_SECRET_NAME=$(read_config ${MANAGED_API_KEY_SECRET_CONFIG}) + aws secretsmanager create-secret --name "$CPK_SECRET_NAME" --secret-string "$${MANAGED_API_KEY}" + npx cdk deploy --all + export ${OPTIONAL_TELEMETRY_ID} + `; + + expect(() => + expectAgentCoreRuntimeDeploymentContract(runtimeDecoy), + ).toThrow(); + expect(() => + expectAgentCoreRuntimeDeploymentContract(runtimeTelemetrySecretDecoy), + ).toThrow(); + expect(() => + expectAgentCoreDeployScriptContract(deployScriptDecoy), + ).toThrow(); + expect(() => + expectAgentCoreDeployScriptContract(lateTelemetryExportDecoy), + ).toThrow(); + expect(() => + expectAgentCoreDeploymentConfigContract( + `${MANAGED_API_KEY_SECRET_CONFIG}: \${${MANAGED_API_KEY}}`, + ), + ).toThrow(); +}); + +test("AgentCore deployment helper rejects endpoint literals disconnected from the root env", () => { + const runtimeDeployment = ` + new lambda.Function(this, "CopilotKitRuntimeLambda", { + environment: { + ${MANAGED_API_KEY}: cdk.SecretValue.secretsManager( + config.${MANAGED_API_KEY_SECRET_CONFIG}, + ).unsafeUnwrap(), + ${OPTIONAL_TELEMETRY_ID}: process.env.${OPTIONAL_TELEMETRY_ID} ?? "", + ${INTELLIGENCE_API_URL}: "https://hard-coded.example", + ${INTELLIGENCE_GATEWAY_WS_URL}: "wss://hard-coded.example", + }, + }); + `; + + expect(() => + expectAgentCoreRuntimeDeploymentContract(runtimeDeployment), + ).toThrow(); +}); + +test("AgentCore README documents caller-overridden remote endpoints for both AWS deploy variants", () => { + const readme = fs.readFileSync( + path.join(integrationsDir, "agentcore", "README.md"), + "utf8", ); - expect(route).toContain("process.env.RESEARCH_AGENT_URL"); - expect(route).toContain("process.env.ANALYSIS_AGENT_URL"); - expect(route).toContain("process.env.ORCHESTRATOR_URL"); - expect(route).toContain('agentId: "a2a_chat"'); - expect(route).toContain("agentUrls: [researchAgentUrl, analysisAgentUrl]"); - expect(route).toContain("orchestrationAgentUrl: orchestratorUrl"); + expectAgentCoreAwsEndpointReadmeContract(readme); }); -test("a2a-middleware page uses REST transport for Threads APIs", () => { - const page = readA2AMiddlewareFile("app/page.tsx"); +for (const scriptName of [ + "deploy-langgraph.sh", + "deploy-strands.sh", +] as const) { + test(`${scriptName} validates AWS endpoint overrides before secret or CDK commands`, () => { + const deployScript = fs.readFileSync( + path.join(integrationsDir, "agentcore", scriptName), + "utf8", + ); - expect(page).toContain('runtimeUrl="/api/copilotkit"'); - expect(page).toContain("useSingleEndpoint={false}"); - expect(page).toContain('agentId="a2a_chat"'); - expect(page).toContain("ThreadsDrawer"); - expect(page).toContain("ThreadsPanelGate"); - expect(page).toContain("CopilotChatConfigurationProvider"); - expect(page).toContain("const [threadId, setThreadId]"); - expect(page).toContain("threadId={threadId}"); -}); + expectAgentCoreAwsEndpointDeployContract(deployScript); + }); -test("a2a-middleware chat keeps A2A visualization tools inside the configured chat", () => { - const chat = readA2AMiddlewareFile("components/chat.tsx"); + for (const invalidEndpoint of [ + { + label: "localhost API URL", + envFile: { + apiUrl: "http://localhost:4201", + gatewayWsUrl: "wss://gateway.example.com/runtime", + }, + expectedVariable: INTELLIGENCE_API_URL, + }, + { + label: "127.0.0.1 gateway URL", + envFile: { + apiUrl: "https://intelligence.example.com/api", + gatewayWsUrl: "ws://127.0.0.1:4401", + }, + expectedVariable: INTELLIGENCE_GATEWAY_WS_URL, + }, + { + label: "Docker host API URL", + envFile: { + apiUrl: "http://host.docker.internal:4201", + gatewayWsUrl: "wss://gateway.example.com/runtime", + }, + expectedVariable: INTELLIGENCE_API_URL, + }, + { + label: "empty gateway URL", + envFile: { + apiUrl: "https://intelligence.example.com/api", + gatewayWsUrl: "", + }, + expectedVariable: INTELLIGENCE_GATEWAY_WS_URL, + }, + ] as const) { + test(`${scriptName} rejects ${invalidEndpoint.label} without exposing the managed key`, () => { + const result = runAgentCoreDeployHarness({ + scriptName, + envFile: invalidEndpoint.envFile, + }); - expect(chat).toContain("useFrontendTool"); - expect(chat).toContain('name: "send_message_to_a2a_agent"'); - expect(chat).toContain("MessageToA2A"); - expect(chat).toContain("MessageFromA2A"); - expect(chat).not.toContain(" { - const envExample = readA2AMiddlewareFile(".env.example"); + test(`${scriptName} accepts sourced HTTPS and WSS endpoints and passes them to CDK`, () => { + const result = runAgentCoreDeployHarness({ + scriptName, + envFile: { + apiUrl: "https://intelligence.example.com/api", + gatewayWsUrl: "wss://gateway.example.com/runtime", + }, + }); - expect(envExample).toContain("GOOGLE_API_KEY="); - expect(envExample).toContain("OPENAI_API_KEY="); - expect(envExample).toContain("COPILOTKIT_LICENSE_TOKEN="); - expect(envExample).toContain("INTELLIGENCE_API_KEY="); - expect(envExample).toContain("INTELLIGENCE_API_URL=http://localhost:4201"); - expect(envExample).toContain( - "INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401", - ); -}); + expect(result.status).toBe(0); + expect(result.cdkEnvironment).toBe( + "https://intelligence.example.com/api\n" + + "wss://gateway.example.com/runtime\n", + ); + expect(result.output).not.toContain(MANAGED_API_KEY_SENTINEL); + expect(result.output).not.toContain(LEGACY_LICENSE_TOKEN_SENTINEL); + }); -test("a2a-middleware package is pinned to the Intelligence-ready CopilotKit SDK", () => { - const packageJson = JSON.parse(readA2AMiddlewareFile("package.json")) as { - dependencies: Record; - }; + test(`${scriptName} passes the updated secret version to CDK without logging it`, () => { + const result = runAgentCoreDeployHarness({ + scriptName, + envFile: { + apiUrl: "https://intelligence.example.com/api", + gatewayWsUrl: "wss://gateway.example.com/runtime", + }, + }); - expect(packageJson.dependencies["@copilotkit/react-core"]).toBe("1.59.3"); - expect(packageJson.dependencies["@copilotkit/runtime"]).toBe("1.59.3"); - expect(packageJson.dependencies["lucide-react"]).toBeDefined(); -}); + expect(result.status).toBe(0); + expect(result.secretVersionId).toBe( + MANAGED_API_KEY_SECRET_VERSION_SENTINEL, + ); + expect(result.licenseTokenSecretVersionId).toBe( + LEGACY_LICENSE_TOKEN_SECRET_VERSION_SENTINEL, + ); + expect(result.output).not.toContain( + MANAGED_API_KEY_SECRET_VERSION_SENTINEL, + ); + expect(result.output).not.toContain( + LEGACY_LICENSE_TOKEN_SECRET_VERSION_SENTINEL, + ); + expect(result.output).not.toContain(MANAGED_API_KEY_SENTINEL); + expect(result.output).not.toContain(LEGACY_LICENSE_TOKEN_SENTINEL); + }); -test("a2a-middleware Next config enables the Threads feature flag", () => { - const nextConfig = readA2AMiddlewareFile("next.config.ts"); + test(`${scriptName} delivers the managed key only through Secrets Manager stdin`, () => { + const result = runAgentCoreDeployHarness({ + scriptName, + envFile: { + apiUrl: "https://intelligence.example.com/api", + gatewayWsUrl: "wss://gateway.example.com/runtime", + }, + skipFrontend: false, + xtrace: true, + }); - expect(nextConfig).toContain('output: "standalone"'); - expect(nextConfig).toContain("NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED"); - expect(nextConfig).toContain("process.env"); - expect(nextConfig).toContain("COPILOTKIT_LICENSE_TOKEN"); -}); + expect(result.status).toBe(0); + expect(result.secretInputObservations).toEqual([ + "aws:key-stdin-match", + "aws:license-stdin-match", + ]); + expect(result.secretExposureObservations).toEqual([]); + expect(result.awsCommands.join("\n")).not.toContain( + MANAGED_API_KEY_SENTINEL, + ); + expect(result.awsCommands.join("\n")).not.toContain( + LEGACY_LICENSE_TOKEN_SENTINEL, + ); + }); -const a2aA2uiRoot = path.join(integrationsDir, "a2a-a2ui"); + test(`${scriptName} --skip-backend leaves backend secret state untouched while deploying the frontend`, () => { + const result = runAgentCoreDeployHarness({ + scriptName, + envFile: { + apiUrl: "https://intelligence.example.com/api", + gatewayWsUrl: "wss://gateway.example.com/runtime", + }, + skipBackend: true, + skipFrontend: false, + }); -function readA2AA2uiFile(pathFromRoot: string): string { - return fs.readFileSync(path.join(a2aA2uiRoot, pathFromRoot), "utf8"); + expect(result.status).toBe(0); + expect(result.awsCommands).toEqual([ + "sts get-caller-identity --query Account --output text", + ]); + expect(result.npmCommands).toEqual([]); + expect(result.cdkEnvironment).toBeNull(); + expect(result.secretVersionId).toBeNull(); + expect(result.licenseTokenSecretVersionId).toBeNull(); + expect(result.frontendStackName).toBe( + scriptName === "deploy-langgraph.sh" + ? "agentcore-contract-lg" + : "agentcore-contract-st", + ); + expect(result.frontendSecretVersionId).toBe(""); + expect(result.secretExposureObservations).toEqual([]); + expect(result.secretInputObservations).toEqual([]); + expect(result.output).toContain("Skipping backend deploy (--skip-backend)"); + expect(result.output).not.toContain( + "Managed Intelligence credentials stored", + ); + expect(result.output).not.toContain(MANAGED_API_KEY_SENTINEL); + expect(result.output).not.toContain(LEGACY_LICENSE_TOKEN_SENTINEL); + expect(result.output).not.toContain( + MANAGED_API_KEY_SECRET_VERSION_SENTINEL, + ); + expect(result.output).not.toContain( + LEGACY_LICENSE_TOKEN_SECRET_VERSION_SENTINEL, + ); + }); + + test(`${scriptName} preserves documented caller endpoint overrides over root localhost defaults`, () => { + const result = runAgentCoreDeployHarness({ + scriptName, + envFile: { + apiUrl: "http://localhost:4201", + gatewayWsUrl: "ws://localhost:4401", + }, + callerEnvironment: { + apiUrl: "https://managed-intelligence.example.com/api", + gatewayWsUrl: "wss://managed-gateway.example.com/runtime", + }, + }); + + expect(result.status).toBe(0); + expect(result.cdkEnvironment).toBe( + "https://managed-intelligence.example.com/api\n" + + "wss://managed-gateway.example.com/runtime\n", + ); + expect(result.output).not.toContain(MANAGED_API_KEY_SENTINEL); + expect(result.output).not.toContain(LEGACY_LICENSE_TOKEN_SENTINEL); + }); } -test("a2a-a2ui runtime route is gated for Intelligence threads", () => { - const route = readA2AA2uiFile("app/api/copilotkit/[[...slug]]/route.tsx"); +test("managed TypeScript helpers reject malformed source", () => { + const malformedRuntime = ` + const intelligence = new CopilotKitIntelligence({ + apiKey: process.env.CPK_INTELLIGENCE_API_KEY, + }); + }`; + const malformedGate = ` + export default { + env: { + NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: + process.env.CPK_INTELLIGENCE_API_KEY ? "true" : "false", + }, + `; - expect(route).toContain("CopilotKitIntelligence"); - expect(route).toContain("class RuntimeA2AAgent extends A2AAgent"); - expect(route).toContain("const isolatedAgent = new A2AAgent"); - expect(route).toContain("isolatedAgent.setMessages(parameters.messages)"); - expect(route).toContain("return isolatedAgent.runAgent("); - expect(route).toContain("process.env.COPILOTKIT_LICENSE_TOKEN"); - expect(route).toContain("process.env.INTELLIGENCE_API_KEY"); - expect(route).toContain("process.env.INTELLIGENCE_API_URL"); - expect(route).toContain("process.env.INTELLIGENCE_GATEWAY_WS_URL"); - expect(route).toContain('id: "demo-user"'); - expect(route).toContain("licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN"); - expect(route).toContain(": { runner: new InMemoryAgentRunner() }"); - expect(route).toContain("a2ui: {}"); - expect(route).toContain("export const GET = handle(app);"); - expect(route).toContain("export const POST = handle(app);"); - expect(route).toContain("export const PATCH = handle(app);"); - expect(route).toContain("export const DELETE = handle(app);"); + expect(() => expectManagedRuntimeContract(malformedRuntime)).toThrow(); + expect(() => expectManagedGateContract(malformedGate)).toThrow(); }); -test("a2a-a2ui page uses REST transport for Threads APIs", () => { - const page = readA2AA2uiFile("app/page.tsx"); - - expect(page).toContain('runtimeUrl="/api/copilotkit"'); - expect(page).toContain('agentId="default"'); - expect(page).toContain("useSingleEndpoint={false}"); - expect(page).toContain("a2ui={{ theme }}"); - expect(page).toContain("const activityRenderers = [a2uiV08Renderer];"); - expect(page).toContain("renderActivityMessages={activityRenderers}"); +test("managed TypeScript helpers accept independent telemetry reads", () => { + const configuredRuntimeRead = ` + const telemetryId = process.env.CPK_TELEMETRY_ID; + const intelligence = new CopilotKitIntelligence({ + apiKey: process.env["CPK_INTELLIGENCE_API_KEY"] ?? "", + }); + const runtime = new CopilotRuntime({ agents: {}, intelligence }); + `; + const configuredGateRead = ` + const telemetryId = process.env["CPK_TELEMETRY_ID"]; + const nextConfig = { + env: { + NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: + process.env.CPK_INTELLIGENCE_API_KEY ? "true" : "false", + }, + }; + export default nextConfig; + `; + expect(() => + expectManagedRuntimeContract(configuredRuntimeRead), + ).not.toThrow(); + expect(() => expectManagedGateContract(configuredGateRead)).not.toThrow(); }); -test("a2a-a2ui page wires a threads drawer into the active chat thread", () => { - const page = readA2AA2uiFile("app/page.tsx"); - - expect(page).toContain("ThreadsDrawer"); - expect(page).toContain("ThreadsPanelGate"); - expect(page).toContain("CopilotChatConfigurationProvider"); - expect(page).toContain("const [threadId, setThreadId]"); - expect(page).toContain('agentId="default"'); - expect(page).toContain("threadId={threadId}"); +test("managed TypeScript helpers reject telemetry prerequisites", () => { + const telemetryGatedRuntime = ` + const intelligence = new CopilotKitIntelligence({ + apiKey: + process.env.CPK_INTELLIGENCE_API_KEY && + process.env.CPK_TELEMETRY_ID, + }); + const runtime = new CopilotRuntime({ agents: {}, intelligence }); + `; + const telemetryGatedNextConfig = ` + const nextConfig = { + env: { + NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: + process.env.CPK_INTELLIGENCE_API_KEY && + process.env.CPK_TELEMETRY_ID + ? "true" + : "false", + }, + }; + export default nextConfig; + `; + expect(() => expectManagedRuntimeContract(telemetryGatedRuntime)).toThrow(); + expect(() => expectManagedGateContract(telemetryGatedNextConfig)).toThrow(); }); -test("a2a-a2ui exposes local Intelligence env documentation", () => { - const envExample = readA2AA2uiFile(".env.example"); - const gitignore = readA2AA2uiFile(".gitignore"); - - expect(envExample).toContain("OPENAI_API_KEY="); - expect(envExample).toContain("COPILOTKIT_LICENSE_TOKEN="); - expect(envExample).toContain("INTELLIGENCE_API_KEY="); - expect(envExample).toContain("INTELLIGENCE_API_URL=http://localhost:4201"); - expect(envExample).toContain( - "INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401", - ); - expect(gitignore).toContain("!.env.example"); -}); - -test("a2a-a2ui package is pinned to the Intelligence-ready CopilotKit SDK", () => { - const packageJson = JSON.parse(readA2AA2uiFile("package.json")) as { - dependencies: Record; - }; - - expect(packageJson.dependencies["@copilotkit/a2ui-renderer"]).toBe("1.59.3"); - expect(packageJson.dependencies["@copilotkit/react-core"]).toBe("1.59.3"); - expect(packageJson.dependencies["@copilotkit/runtime"]).toBe("1.59.3"); - expect(packageJson.dependencies["lucide-react"]).toBeDefined(); -}); - -test("a2a-a2ui Next config enables the Threads feature flag", () => { - const nextConfig = readA2AA2uiFile("next.config.js"); - - expect(nextConfig).toContain('output: "standalone"'); - expect(nextConfig).toContain( - "NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN", - ); -}); - -const agentcoreRoot = path.join(integrationsDir, "agentcore"); - -function readAgentcoreFile(pathFromRoot: string): string { - return fs.readFileSync(path.join(agentcoreRoot, pathFromRoot), "utf8"); +/** Build the exact managed dotenv sections with telemetry semantics documented. */ +function managedEnvExample( + telemetryDescription = "Optional, non-secret analytics identity.", +): string { + return [ + "# Your CopilotKit Enterprise Intelligence API Key", + "# Project Name: xxxxxxxxxx", + "CPK_INTELLIGENCE_API_KEY=xxxxx", + "", + "# CopilotKit Telemetry ID", + `# ${telemetryDescription}`, + "CPK_TELEMETRY_ID=xxxxx", + ].join("\n"); } -describe("agentcore Intelligence integration migration", () => { - it("gates the Hono runtime bridge with CopilotKit Intelligence", () => { - const runtime = readAgentcoreFile( - "infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts", +test("managed documentation helpers reject misleading telemetry semantics", () => { + const misleadingDescriptions = [ + "authentication credential", + "entitlement identity", + "feature enablement identity", + "analytics consent identity", + "telemetry send-policy identity", + ]; + + for (const description of misleadingDescriptions) { + const envExample = managedEnvExample( + `Optional, non-secret analytics ${description}.`, ); + const readme = [ + "## Managed Intelligence credentials", + "", + "Set `CPK_INTELLIGENCE_API_KEY` for the project.", + "", + `\`CPK_TELEMETRY_ID\` is an optional, non-secret analytics ${description}.`, + ].join("\n"); - expect(runtime).toContain("CopilotKitIntelligence"); - expect(runtime).toContain("process.env.COPILOTKIT_LICENSE_TOKEN"); - expect(runtime).toContain("process.env.INTELLIGENCE_API_KEY"); - expect(runtime).toContain("process.env.INTELLIGENCE_API_URL"); - expect(runtime).toContain("process.env.INTELLIGENCE_GATEWAY_WS_URL"); - expect(runtime).toContain( - "licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN", - ); - expect(runtime).toContain('id: "demo-user"'); - expect(runtime).toContain(": { runner: new AgentCoreRunner() }"); - expect(runtime).toContain('basePath: "/copilotkit"'); - }); + expect(() => expectManagedEnvContract(envExample)).toThrow(); + expect(() => expectManagedReadmeContract(readme)).toThrow(); + } +}); - it("forces REST transport and threads context in the Vite frontend", () => { - const chat = readAgentcoreFile( - "frontend/src/components/chat/CopilotKit/index.tsx", - ); +test("managed documentation helpers accept an optional non-secret analytics identity", () => { + const envExample = managedEnvExample(); + const readme = [ + "## Managed Intelligence credentials", + "", + "Set `CPK_INTELLIGENCE_API_KEY` for the project.", + "", + "`CPK_TELEMETRY_ID` is an optional, non-secret analytics identity.", + "", + "## Offline or self-hosted licensing", + "", + "Set `COPILOTKIT_LICENSE_TOKEN` only for an offline deployment.", + ].join("\n"); - expect(chat).toContain("useSingleEndpoint={false}"); - expect(chat).toContain("ThreadsDrawer"); - expect(chat).toContain("ThreadsPanelGate"); - expect(chat).toContain("CopilotChatConfigurationProvider"); - expect(chat).toContain("const [threadId, setThreadId]"); - expect(chat).toContain("threadId={threadId}"); - expect(chat).toContain("runtimeUrl={runtimeUrl}"); - expect(chat).toContain("headers={headers}"); - }); + expect(() => expectManagedEnvContract(envExample)).not.toThrow(); + expect(() => expectManagedReadmeContract(readme)).not.toThrow(); +}); - it("exposes the client-safe threads enabled gate for Vite", () => { - const viteConfig = readAgentcoreFile("frontend/vite.config.ts"); - const lockedState = readAgentcoreFile( - "frontend/src/components/threads-drawer/locked-state.tsx", - ); +test("pinned SDK compatibility rejects a key-only stale template", () => { + const pins = [ + { + packageName: "@copilotkit/runtime", + packagePath: "package.json", + version: "1.62.3", + }, + { + packageName: "@copilotkit/react-core", + packagePath: "package.json", + version: "1.62.3", + }, + ] as const satisfies readonly ManagedSdkPin[]; + const runtime = ` + new CopilotRuntime({ + agents: {}, + intelligence: new CopilotKitIntelligence({ + apiKey: process.env.CPK_INTELLIGENCE_API_KEY, + }), + }); + `; + const readme = [ + "## Managed Intelligence credentials", + "", + "Set CPK_INTELLIGENCE_API_KEY for the project.", + "CPK_TELEMETRY_ID is an optional, non-secret analytics identity.", + ].join("\n"); - expect(viteConfig).toContain("VITE_COPILOTKIT_THREADS_ENABLED"); - expect(viteConfig).toContain("process.env.COPILOTKIT_LICENSE_TOKEN"); - expect(lockedState).toContain( - "import.meta.env.VITE_COPILOTKIT_THREADS_ENABLED", - ); - }); + expect(() => + expectPinnedSdkCompatibilityContract( + pins, + runtime, + managedEnvExample(), + readme, + ), + ).toThrow(); +}); - it("documents and wires local Intelligence environment variables", () => { - const dockerEnv = readAgentcoreFile("docker/.env.example"); - const compose = readAgentcoreFile("docker/docker-compose.yml"); +test("managed SDK compatibility rejects unclassified package versions", () => { + const pins = [ + { + packageName: "@copilotkit/runtime", + packagePath: "package.json", + version: "9.9.9", + }, + ] as const satisfies readonly ManagedSdkPin[]; - for (const envName of [ - "COPILOTKIT_LICENSE_TOKEN", - "INTELLIGENCE_API_KEY", - "INTELLIGENCE_API_URL", - "INTELLIGENCE_GATEWAY_WS_URL", - ]) { - expect(dockerEnv).toContain(envName); - expect(compose).toContain(envName); + expect(() => expectPinnedSdkCompatibilityContract(pins, "", "", "")).toThrow( + /must be classified/, + ); +}); + +test("managed README helper rejects missing offline or self-hosted license guidance", () => { + const readme = [ + "## Managed Intelligence credentials", + "", + "Set `CPK_INTELLIGENCE_API_KEY` for the project.", + "", + "`CPK_TELEMETRY_ID` is an optional, non-secret analytics identity.", + ].join("\n"); + + expect(() => expectManagedReadmeContract(readme)).toThrow(); +}); + +test.each([ + { + invalidLayout: "two blank lines between sections", + envExample: managedEnvExample().replace( + "CPK_INTELLIGENCE_API_KEY=xxxxx\n\n# CopilotKit", + "CPK_INTELLIGENCE_API_KEY=xxxxx\n\n\n# CopilotKit", + ), + }, + { + invalidLayout: "duplicate telemetry section", + envExample: `${managedEnvExample()}\n\n# CopilotKit Telemetry ID\nCPK_TELEMETRY_ID=duplicate`, + }, +])("managed documentation helpers reject $invalidLayout", ({ envExample }) => { + expect(() => expectManagedEnvContract(envExample)).toThrow(); +}); + +test("managed documentation helpers reject license guidance inside the telemetry section", () => { + const envExample = `${managedEnvExample()}\nCOPILOTKIT_LICENSE_TOKEN=`; + const readme = [ + "## Managed Intelligence credentials", + "", + "Set CPK_INTELLIGENCE_API_KEY for the project.", + "CPK_TELEMETRY_ID is an optional, non-secret analytics identity.", + "COPILOTKIT_LICENSE_TOKEN is not a managed credential.", + ].join("\n"); + + expect(() => expectManagedEnvContract(envExample)).toThrow(); + expect(() => expectManagedReadmeContract(readme)).toThrow(); +}); + +test("managed documentation helpers reject separated unlabeled license guidance", () => { + const envExample = [ + managedEnvExample(), + "", + "# Self-hosted / offline license", + "COPILOTKIT_LICENSE_TOKEN=", + "", + "# Troubleshooting", + "COPILOTKIT_LICENSE_TOKEN=", + ].join("\n"); + const readme = [ + "## Managed Intelligence credentials", + "", + "Set CPK_INTELLIGENCE_API_KEY for the project.", + "CPK_TELEMETRY_ID is an optional, non-secret analytics identity.", + "", + "## Self-hosted / offline license", + "", + "Offline deployments may set COPILOTKIT_LICENSE_TOKEN.", + "", + "## Troubleshooting", + "", + "Set COPILOTKIT_LICENSE_TOKEN if the managed integration is unavailable.", + ].join("\n"); + + expect(() => expectManagedEnvContract(envExample)).toThrow(); + expect(() => expectManagedReadmeContract(readme)).toThrow(); +}); + +test("managed documentation helpers reject reordered managed dotenv sections", () => { + const envExample = [ + "# CopilotKit Telemetry ID", + "# Optional, non-secret analytics identity.", + "CPK_TELEMETRY_ID=xxxxx", + "", + "# Your CopilotKit Enterprise Intelligence API Key", + "# Project Name: xxxxxxxxxx", + "CPK_INTELLIGENCE_API_KEY=xxxxx", + ].join("\n"); + const readme = [ + "## Managed Intelligence credentials", + "", + "Set CPK_INTELLIGENCE_API_KEY for the project.", + "", + "## Analytics identity", + "", + "CPK_TELEMETRY_ID is an optional, non-secret analytics identity.", + ].join("\n"); + + expect(() => expectManagedEnvContract(envExample)).toThrow(); + expect(() => expectManagedReadmeContract(readme)).toThrow(); +}); + +test("managed documentation helpers allow separate self-hosted and offline license guidance", () => { + const envExample = [ + managedEnvExample(), + "", + "# Self-hosted / offline license", + "COPILOTKIT_LICENSE_TOKEN=", + ].join("\n"); + const readme = [ + "## Managed Intelligence credentials", + "", + "Set CPK_INTELLIGENCE_API_KEY for the project.", + "CPK_TELEMETRY_ID is an optional, non-secret analytics identity.", + "", + "## Self-hosted / offline license", + "", + "Offline deployments may set COPILOTKIT_LICENSE_TOKEN instead.", + ].join("\n"); + + expect(() => expectManagedEnvContract(envExample)).not.toThrow(); + expect(() => expectManagedReadmeContract(readme)).not.toThrow(); +}); + +test("managed documentation helpers reject unlabeled generic license-gating copy without an env literal", () => { + const envExample = [ + managedEnvExample(), + "", + "# Product capabilities", + "# A license unlocks Threads and enables Inspector.", + ].join("\n"); + const readme = [ + "## Managed Intelligence credentials", + "", + "Set CPK_INTELLIGENCE_API_KEY for the project.", + "CPK_TELEMETRY_ID is an optional, non-secret analytics identity.", + "", + "## Product capabilities", + "", + "A license activates Threads and unlocks Inspector.", + ].join("\n"); + + expect(() => expectManagedEnvContract(envExample)).toThrow(); + expect(() => expectManagedReadmeContract(readme)).toThrow(); +}); + +test("managed documentation helpers allow generic license-gating copy only in self-hosted or offline sections", () => { + const envExample = [ + managedEnvExample(), + "", + "# Self-hosted / offline license behavior", + "# A deployment license enables Threads and Inspector offline.", + ].join("\n"); + const readme = [ + "## Managed Intelligence credentials", + "", + "Set CPK_INTELLIGENCE_API_KEY for the project.", + "CPK_TELEMETRY_ID is an optional, non-secret analytics identity.", + "", + "## Self-hosted / offline license behavior", + "", + "Set COPILOTKIT_LICENSE_TOKEN only for this deployment mode.", + "A deployment license enables Threads and Inspector offline.", + ].join("\n"); + + expect(() => expectManagedEnvContract(envExample)).not.toThrow(); + expect(() => expectManagedReadmeContract(readme)).not.toThrow(); +}); + +test("route preservation helper rejects an incomplete handler export set", () => { + const route = ` + const app = createCopilotEndpoint({ runtime, basePath: "/api/copilotkit" }); + export const GET = handle(app); + export const POST = handle(app); + export const PATCH = handle(app); + `; + + expect(() => expectEndpointHandlerContract(route)).toThrow(); +}); + +test("frontend preservation helper rejects controlled thread state and single-endpoint transport", () => { + const frontend = ` + export function App() { + return ( + + + + + + + ); } - }); + `; - it("pins AgentCore frontend and runtime packages to threads-capable versions", () => { - const frontendPackageJson = JSON.parse( - readAgentcoreFile("frontend/package.json"), - ) as { dependencies?: Record }; - const runtimePackageJson = JSON.parse( - readAgentcoreFile("infra-cdk/lambdas/copilotkit-runtime/package.json"), - ) as { dependencies?: Record }; - - expect(frontendPackageJson.dependencies?.["@copilotkit/react-core"]).toBe( - "1.59.3", - ); - expect(runtimePackageJson.dependencies?.["@copilotkit/runtime"]).toBe( - "1.59.3", - ); - expect(runtimePackageJson.dependencies?.["@ag-ui/client"]).toBe("0.0.53"); - }); + expect(() => expectFrontendThreadContract(frontend, frontend)).toThrow(); }); + +test("MCP preservation helper rejects a middleware-only scaffold without server behavior", () => { + const server = ` + export function createServer() { + return new McpServer({ name: "Three.js Server", version: "1.0.0" }); + } + `; + const transport = ` + export function startServer() { + const app = createMcpExpressApp(); + app.all("/mcp", () => undefined); + } + `; + + expect(() => expectMcpServerBehavior(server, transport)).toThrow(); +}); + +test("A2A preservation helper rejects collapsed single-agent routing", () => { + const route = ` + const agent = new RuntimeA2AMiddlewareAgent({ + agentId: "a2a_chat", + agentUrls: [], + orchestrationAgentUrl: "http://localhost:9000", + }); + `; + + expect(() => expectA2ARuntimeBehavior(route)).toThrow(); +}); + +test("AgentCore preservation helper rejects disconnected local services", () => { + const compose = ` +services: + agent: + image: agent + bridge: + image: bridge + frontend: + image: frontend +`; + + expect(() => expectAgentCoreNetworkingBehavior(compose)).toThrow(); +}); + +test("AgentCore preservation helper rejects a custom runner disconnected from Runtime selection", () => { + const runtime = ` + class AgentCoreRunner extends InMemoryAgentRunner {} + const disconnectedRunner = new AgentCoreRunner(); + const runtime = process.env.${MANAGED_API_KEY} + ? new CopilotRuntime({ + agents: {}, + intelligence: new CopilotKitIntelligence({ + apiKey: process.env.${MANAGED_API_KEY}, + }), + identifyUser: () => ({ id: "demo-user" }), + }) + : new CopilotRuntime({ + agents: {}, + runner: new InMemoryAgentRunner(), + }); + const app = createCopilotEndpoint({ runtime, basePath: "/copilotkit" }); + `; + + expect(() => expectAgentCoreRuntimeBehavior(runtime)).toThrow(); +}); + +test("integration parity workflow preserves filters and runs parity before the expected-red managed contract", () => { + const workflow = fs.readFileSync(INTEGRATION_PARITY_WORKFLOW, "utf8"); + + expectIntegrationParityWorkflowContract(workflow); +}); + +test("integration parity workflow helper rejects missing filters and reversed contract order", () => { + const paths = [ + "examples/integrations/**", + "scripts/__tests__/integration-intelligence-migration.test.ts", + ".github/workflows/integrations_parity.yml", + ]; + const workflow = (eventPaths: readonly string[], reversed: boolean) => ` +on: + pull_request: + paths: +${renderWorkflowPaths(eventPaths)} + push: + paths: +${renderWorkflowPaths(paths)} +jobs: + parity-check: + steps: + - name: ${reversed ? "Verify Intelligence template credential contracts" : "Verify integration-demo parity"} + run: ${reversed ? "pnpm exec vitest run scripts/__tests__/integration-intelligence-migration.test.ts" : "pnpm parity:check"} + - name: ${reversed ? "Verify integration-demo parity" : "Verify Intelligence template credential contracts"} + run: ${reversed ? "pnpm parity:check" : "pnpm exec vitest run scripts/__tests__/integration-intelligence-migration.test.ts"} +`; + + expect(() => + expectIntegrationParityWorkflowContract(workflow(paths, true)), + ).toThrow(); + expect(() => + expectIntegrationParityWorkflowContract(workflow(paths.slice(0, 2), false)), + ).toThrow(); +}); + +test("the 17 Intelligence template directories back all 19 in-repo CLI frameworks", () => { + const frameworks = INTELLIGENCE_TEMPLATE_CONTRACTS.flatMap( + (contract) => contract.frameworks, + ); + const ordinaryRuntimeContracts = INTELLIGENCE_TEMPLATE_CONTRACTS.filter( + (contract) => "runtimeAgent" in contract, + ); + + expect(INTELLIGENCE_TEMPLATE_CONTRACTS).toHaveLength(17); + expect(ordinaryRuntimeContracts).toHaveLength(14); + expect(new Set(frameworks).size).toBe(19); + expect([...frameworks].sort()).toEqual( + [...INTELLIGENCE_CLI_FRAMEWORKS].sort(), + ); +}); + +test.each([ + { + defect: "registered constructor changes", + contents: ` + const defaultAgent = new WrongAgent({ + url: process.env.AGENT_URL || "http://localhost:8000/", + }); + new CopilotRuntime({ agents: { default: defaultAgent } }); + `, + contract: HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT, + }, + { + defect: "required endpoint configuration disappears", + contents: ` + const defaultAgent = new HttpAgent({ + url: "http://localhost:8000/", + }); + new CopilotRuntime({ agents: { default: defaultAgent } }); + `, + contract: HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT, + }, + { + defect: "constructed agent is no longer registered", + contents: ` + const defaultAgent = new HttpAgent({ + url: process.env.AGENT_URL || "http://localhost:8000/", + }); + new CopilotRuntime({ agents: { default: unregisteredAgent } }); + `, + contract: HTTP_LOCALHOST_SLASH_RUNTIME_AGENT_CONTRACT, + }, + { + defect: "framework factory changes", + contents: ` + new CopilotRuntime({ + agents: MastraAgent.getRemoteAgents({ mastra }), + }); + `, + contract: MASTRA_RUNTIME_AGENT_CONTRACT, + }, +])( + "the ordinary Runtime agent helper rejects when $defect", + ({ contents, contract }) => { + expect(() => expectRuntimeAgentContract(contents, contract)).toThrow(); + }, +); + +for (const contract of INTELLIGENCE_TEMPLATE_CONTRACTS) { + if ("runtimeAgent" in contract) { + test(`${contract.directory} runtime preserves its framework-specific agent wiring`, () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + + expectRuntimeAgentContract(runtime, contract.runtimeAgent); + }); + } + + if (contract.directory !== "agentcore") { + test(`${contract.directory} runtime preserves all REST endpoint handlers`, () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + + expectEndpointHandlerContract(runtime); + }); + } + + test(`${contract.directory} frontend preserves REST transport and shared thread context`, () => { + const frontendPaths = frontendBehaviorPaths(contract); + const provider = readManagedSurface( + contract, + frontendPaths.providerPath, + "frontend provider", + ); + const threadSurface = readManagedSurface( + contract, + frontendPaths.threadPath, + "thread surface", + ); + + expectFrontendThreadContract(provider, threadSurface); + }); + + if (contract.directory !== "agentcore") { + test(`${contract.directory} defers the no-token managed drawer until its pinned SDK supports structured entitlements`, () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + const gate = readManagedSurface( + contract, + contract.gatePath, + "client gate", + ); + const readme = readManagedSurface( + contract, + contract.readmePath, + "README", + ); + const pins = readManagedSdkPins(contract); + + expect(pins.every((pin) => !pinSupportsManagedEntitlements(pin))).toBe( + true, + ); + expect(runtime).toMatch(exactEnvIdentifierPattern(MANAGED_LICENSE_TOKEN)); + expect(gate).toMatch(exactEnvIdentifierPattern(MANAGED_LICENSE_TOKEN)); + for (const contents of [runtime, gate, readme]) { + expect(contents).not.toMatch( + exactEnvIdentifierPattern(MANAGED_API_KEY), + ); + expect(contents).not.toMatch( + exactEnvIdentifierPattern(OPTIONAL_TELEMETRY_ID), + ); + } + }); + } + + if (contract.directory === "agentcore") { + test(`${contract.directory} runtime uses the managed Intelligence API key`, () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + + expectManagedRuntimeContract(runtime); + }); + + test(`${contract.directory} keeps entitlement compatibility for its pinned SDKs`, () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + const envExample = readManagedSurface( + contract, + contract.envPath, + "env example", + ); + const readme = readManagedSurface( + contract, + contract.readmePath, + "README", + ); + + expectPinnedSdkCompatibilityContract( + readManagedSdkPins(contract), + runtime, + envExample, + readme, + ); + }); + + test(`${contract.directory} env example documents managed Intelligence credentials`, () => { + const envExample = readManagedSurface( + contract, + contract.envPath, + "env example", + ); + + expectManagedEnvContract(envExample); + }); + + test(`${contract.directory} README documents managed Intelligence credentials`, () => { + const readme = readManagedSurface( + contract, + contract.readmePath, + "README", + ); + + expectManagedReadmeContract(readme); + }); + } + + if (contract.directory === "mcp-apps") { + test("mcp-apps runtime preserves its MCP Apps client middleware", () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + + expectMcpAppsRuntimeBehavior(runtime); + }); + + test("mcp-apps preserves its streamable HTTP tools and UI resource server", () => { + const server = readManagedSurface( + contract, + "threejs-server/server.ts", + "MCP server", + ); + const transport = readManagedSurface( + contract, + "threejs-server/server-utils.ts", + "MCP transport", + ); + + expectMcpServerBehavior(server, transport); + }); + } + + if (contract.directory === "a2a-middleware") { + test("a2a-middleware preserves isolated multi-agent routing", () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + + expectA2ARuntimeBehavior(runtime); + }); + + test("a2a-middleware preserves its configured chat visualization tool", () => { + const chat = readManagedSurface( + contract, + "components/chat.tsx", + "A2A chat", + ); + + expectA2AVisualizationBehavior(chat); + }); + } + + if ("supportedPaths" in contract) { + const supportedPaths = contract.supportedPaths; + test(`${contract.directory} runtime preserves AgentCore bridge behavior`, () => { + const runtime = readManagedSurface( + contract, + contract.runtimePath, + "runtime", + ); + + expectAgentCoreRuntimeBehavior(runtime); + }); + + test(`${contract.directory} local Compose preserves service networking`, () => { + const compose = readManagedSurface( + contract, + supportedPaths.localComposePath, + "local Compose config", + ); + + expectAgentCoreNetworkingBehavior(compose); + }); + + test(`${contract.directory} deploy scripts preserve LangGraph and Strands variants`, () => { + const variants = [ + { + path: supportedPaths.deployScriptPaths[0]!, + pattern: "langgraph-single-agent", + suffix: "-lg", + }, + { + path: supportedPaths.deployScriptPaths[1]!, + pattern: "strands-single-agent", + suffix: "-st", + }, + ]; + + for (const variant of variants) { + const deployScript = readManagedSurface( + contract, + variant.path, + "deploy script", + ); + expectAgentCoreVariantBehavior( + deployScript, + variant.pattern, + variant.suffix, + ); + expect( + fs.existsSync( + path.join( + integrationsDir, + contract.directory, + "agents", + variant.pattern, + ), + ), + ).toBe(true); + } + }); + + test(`${contract.directory} local Compose consumes the root managed env`, () => { + const compose = readManagedSurface( + contract, + supportedPaths.localComposePath, + "local Compose config", + ); + + expectAgentCoreLocalComposeContract(compose); + }); + + test(`${contract.directory} deployment config stores a managed key secret reference`, () => { + const deploymentConfig = readManagedSurface( + contract, + supportedPaths.deploymentConfigPath, + "deployment config", + ); + + expectAgentCoreDeploymentConfigContract(deploymentConfig); + }); + + test(`${contract.directory} deployed Lambda resolves the managed key secret`, () => { + const runtimeDeployment = readManagedSurface( + contract, + supportedPaths.runtimeDeploymentPath, + "runtime deployment config", + ); + + expectAgentCoreRuntimeDeploymentContract(runtimeDeployment); + }); + + test(`${contract.directory} deploys the pinned SDK license token through Secrets Manager`, () => { + const deploymentConfig = readManagedSurface( + contract, + supportedPaths.deploymentConfigPath, + "deployment config", + ); + const runtimeDeployment = readManagedSurface( + contract, + supportedPaths.runtimeDeploymentPath, + "runtime deployment config", + ); + const deployScripts = supportedPaths.deployScriptPaths.map( + (deployScriptPath) => + readManagedSurface(contract, deployScriptPath, "deploy script"), + ); + + expectAgentCoreLegacyLicenseDeploymentContract( + deploymentConfig, + runtimeDeployment, + deployScripts, + ); + }); + + test(`${contract.directory} explicitly excludes managed credentials from Terraform`, () => { + const terraformRuntimeDeployment = readManagedSurface( + contract, + supportedPaths.terraformRuntimeDeploymentPath, + "Terraform runtime deployment", + ); + const terraformReadme = readManagedSurface( + contract, + supportedPaths.terraformReadmePath, + "Terraform README", + ); + + expectAgentCoreTerraformExclusionContract( + terraformRuntimeDeployment, + terraformReadme, + ); + }); + + test(`${contract.directory} deploy scripts materialize the configured managed key secret`, () => { + for (const deployScriptPath of supportedPaths.deployScriptPaths) { + const deployScript = readManagedSurface( + contract, + deployScriptPath, + "deploy script", + ); + expectAgentCoreDeployScriptContract(deployScript); + } + }); + } +} + +/** Assert AgentCore's AWS instructions require caller-provided remote endpoints. */ +function expectAgentCoreAwsEndpointReadmeContract(contents: string): void { + expect(contents).toMatch(/managed or self-hosted Intelligence endpoints/i); + expect(contents).toMatch(/reachable from AWS/i); + expect(contents).toMatch( + /must not use[\s`]*localhost[\s\S]{0,40}127\.0\.0\.1/i, + ); + + for (const scriptName of [ + "deploy-langgraph.sh", + "deploy-strands.sh", + ] as const) { + expect(contents).toMatch( + new RegExp( + `INTELLIGENCE_API_URL=https://[^\\s]+[\\s\\S]{0,200}` + + `INTELLIGENCE_GATEWAY_WS_URL=wss://[^\\s]+[\\s\\S]{0,200}` + + `\\./${scriptName.replace(".", "\\.")}`, + ), + ); + } +} + +/** Assert one AgentCore deploy script validates remote endpoints before AWS writes. */ +function expectAgentCoreAwsEndpointDeployContract(contents: string): void { + const normalized = contents.replace(/\\\r?\n\s*/g, " "); + const lines = normalized.split(/\r?\n/); + const rootEnvLoadIndex = lines.findIndex((line) => + /(?:^|\s)(?:source|\.)\s+["']?\$\{?SCRIPT_DIR\}?\/\.env["']?(?:\s|$)/.test( + line, + ), + ); + const apiGuardIndex = lines.findIndex((line) => + /require_remote_endpoint\s+INTELLIGENCE_API_URL\b/.test(line), + ); + const gatewayGuardIndex = lines.findIndex((line) => + /require_remote_endpoint\s+INTELLIGENCE_GATEWAY_WS_URL\b/.test(line), + ); + const firstAwsWriteIndex = lines.findIndex((line) => + /aws\s+secretsmanager\s+(?:create-secret|put-secret-value)\b/.test(line), + ); + const cdkDeployIndex = lines.findIndex((line) => + /npx\s+cdk(?:@\S+)?\s+deploy\b/.test(line), + ); + + expect(contents).toContain("${INTELLIGENCE_API_URL+x}"); + expect(contents).toContain("${INTELLIGENCE_GATEWAY_WS_URL+x}"); + expect(contents).toContain("localhost"); + expect(contents).toContain("127\\.0\\.0\\.1"); + expect(apiGuardIndex).toBeGreaterThan(rootEnvLoadIndex); + expect(gatewayGuardIndex).toBeGreaterThan(rootEnvLoadIndex); + expect(firstAwsWriteIndex).toBeGreaterThan( + Math.max(apiGuardIndex, gatewayGuardIndex), + ); + expect(cdkDeployIndex).toBeGreaterThan( + Math.max(apiGuardIndex, gatewayGuardIndex), + ); +} + +/** Write one executable command stub into an AgentCore deploy harness. */ +function writeAgentCoreCommandStub( + directory: string, + command: string, + contents = "#!/usr/bin/env bash\nexit 0\n", +): void { + const commandPath = path.join(directory, command); + fs.writeFileSync(commandPath, contents); + fs.chmodSync(commandPath, 0o755); +} + +/** Render one optional endpoint into a shell environment assignment. */ +function agentCoreEndpointAssignment(name: string, value?: string): string { + return value === undefined ? "" : `${name}=${value}\n`; +} + +/** Read one optional newline-delimited harness capture. */ +function readAgentCoreHarnessLines(capturePath: string): string[] { + if (!fs.existsSync(capturePath)) { + return []; + } + return fs + .readFileSync(capturePath, "utf8") + .split("\n") + .filter((line) => line.length > 0); +} + +/** Emit a shell probe that records any backend-credential environment leak. */ +function agentCoreSecretExposureProbe(command: string): string { + return [ + `if [[ -n "\${${MANAGED_API_KEY}-}" ]]; then`, + ` printf '%s\\n' '${command}:env' >> "\${SECRET_EXPOSURE_CAPTURE:?}"`, + "fi", + `if [[ -n "\${${MANAGED_LICENSE_TOKEN}-}" ]]; then`, + ` printf '%s\\n' '${command}:license-env' >> "\${SECRET_EXPOSURE_CAPTURE:?}"`, + "fi", + `if [[ "$*" == *'${MANAGED_API_KEY_SENTINEL}'* ]]; then`, + ` printf '%s\\n' '${command}:argv' >> "\${SECRET_EXPOSURE_CAPTURE:?}"`, + "fi", + `if [[ "$*" == *'${LEGACY_LICENSE_TOKEN_SENTINEL}'* ]]; then`, + ` printf '%s\\n' '${command}:license-argv' >> "\${SECRET_EXPOSURE_CAPTURE:?}"`, + "fi", + ].join("\n"); +} + +/** Execute one real AgentCore deploy script against non-networking command stubs. */ +function runAgentCoreDeployHarness( + options: AgentCoreDeployHarnessOptions, +): AgentCoreDeployHarnessResult { + const harnessDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "agentcore-deploy-contract-"), + ); + try { + const fakeBin = path.join(harnessDirectory, "bin"); + const cdkDirectory = path.join(harnessDirectory, "infra-cdk"); + const capturePath = path.join(harnessDirectory, "cdk-environment.txt"); + const awsCapturePath = path.join(harnessDirectory, "aws-commands.txt"); + const npmCapturePath = path.join(harnessDirectory, "npm-commands.txt"); + const frontendCapturePath = path.join( + harnessDirectory, + "frontend-environment.txt", + ); + const secretExposureCapturePath = path.join( + harnessDirectory, + "secret-exposure.txt", + ); + const secretInputCapturePath = path.join( + harnessDirectory, + "secret-input.txt", + ); + const scriptPath = path.join(harnessDirectory, options.scriptName); + fs.mkdirSync(fakeBin); + fs.mkdirSync(cdkDirectory); + fs.mkdirSync(path.join(harnessDirectory, "scripts")); + fs.copyFileSync( + path.join(integrationsDir, "agentcore", options.scriptName), + scriptPath, + ); + fs.writeFileSync( + path.join(harnessDirectory, ".env"), + [ + `${MANAGED_API_KEY}=${MANAGED_API_KEY_SENTINEL}\n`, + `${MANAGED_LICENSE_TOKEN}=${LEGACY_LICENSE_TOKEN_SENTINEL}\n`, + `${OPTIONAL_TELEMETRY_ID}=agentcore-test-telemetry\n`, + agentCoreEndpointAssignment( + INTELLIGENCE_API_URL, + options.envFile.apiUrl, + ), + agentCoreEndpointAssignment( + INTELLIGENCE_GATEWAY_WS_URL, + options.envFile.gatewayWsUrl, + ), + ].join(""), + ); + fs.writeFileSync( + path.join(harnessDirectory, "config.yaml"), + [ + "stack_name_base: agentcore-contract\n", + `${MANAGED_API_KEY_SECRET_CONFIG}: agentcore/contract/key\n`, + `${LEGACY_LICENSE_TOKEN_SECRET_CONFIG}: agentcore/contract/license-token\n`, + "backend:\n", + " pattern: placeholder\n", + ].join(""), + ); + + for (const command of ["docker", "node"]) { + writeAgentCoreCommandStub(fakeBin, command); + } + writeAgentCoreCommandStub( + fakeBin, + "npm", + [ + "#!/usr/bin/env bash\n", + "set -eu\n", + `${agentCoreSecretExposureProbe("npm")}\n`, + 'printf \'%s\\n\' "$*" >> "${NPM_COMMAND_CAPTURE:?}"\n', + ].join(""), + ); + writeAgentCoreCommandStub( + fakeBin, + "aws", + [ + "#!/usr/bin/env bash\n", + "set -eu\n", + `${agentCoreSecretExposureProbe("aws")}\n`, + 'printf \'%s\\n\' "$*" >> "${AWS_COMMAND_CAPTURE:?}"\n', + 'if [[ "$*" == *"secretsmanager put-secret-value"* || "$*" == *"secretsmanager create-secret"* ]]; then\n', + " secret_input=$(cat)\n", + ` if [[ "$secret_input" == '${MANAGED_API_KEY_SENTINEL}' ]]; then\n`, + " printf '%s\\n' 'aws:key-stdin-match' >> \"${SECRET_INPUT_CAPTURE:?}\"\n", + ` printf '%s\\n' '${MANAGED_API_KEY_SECRET_VERSION_SENTINEL}'\n`, + ` elif [[ "$secret_input" == '${LEGACY_LICENSE_TOKEN_SENTINEL}' ]]; then\n`, + " printf '%s\\n' 'aws:license-stdin-match' >> \"${SECRET_INPUT_CAPTURE:?}\"\n", + ` printf '%s\\n' '${LEGACY_LICENSE_TOKEN_SECRET_VERSION_SENTINEL}'\n`, + " else\n", + " printf '%s\\n' 'aws:stdin-missing' >> \"${SECRET_INPUT_CAPTURE:?}\"\n", + " fi\n", + "fi\n", + ].join(""), + ); + writeAgentCoreCommandStub( + fakeBin, + "npx", + [ + "#!/usr/bin/env bash\n", + "set -eu\n", + `${agentCoreSecretExposureProbe("npx")}\n`, + `printf '%s\\n%s\\n%s\\n%s\\n' "\${${INTELLIGENCE_API_URL}-}" "\${${INTELLIGENCE_GATEWAY_WS_URL}-}" "\${${MANAGED_API_KEY_SECRET_VERSION_ID}-}" "\${${LEGACY_LICENSE_TOKEN_SECRET_VERSION_ID}-}" > "\${CDK_ENV_CAPTURE:?}"\n`, + ].join(""), + ); + fs.writeFileSync( + path.join(harnessDirectory, "scripts", "deploy-frontend.py"), + [ + "import os\n", + "import pathlib\n", + "import sys\n", + `if os.environ.get('${MANAGED_API_KEY}'):\n`, + ' with pathlib.Path(os.environ["SECRET_EXPOSURE_CAPTURE"]).open("a") as capture:\n', + ' capture.write("frontend:env\\n")\n', + `if os.environ.get('${MANAGED_LICENSE_TOKEN}'):\n`, + ' with pathlib.Path(os.environ["SECRET_EXPOSURE_CAPTURE"]).open("a") as capture:\n', + ' capture.write("frontend:license-env\\n")\n', + 'pathlib.Path(os.environ["FRONTEND_CAPTURE"]).write_text(\n', + ` f"{sys.argv[1]}\\n{os.environ.get('${MANAGED_API_KEY_SECRET_VERSION_ID}', '')}\\n"\n`, + ")\n", + ].join(""), + ); + + const environment = { ...process.env }; + environment[MANAGED_API_KEY] = MANAGED_API_KEY_SENTINEL; + environment[MANAGED_LICENSE_TOKEN] = LEGACY_LICENSE_TOKEN_SENTINEL; + delete environment[INTELLIGENCE_API_URL]; + delete environment[INTELLIGENCE_GATEWAY_WS_URL]; + delete environment[MANAGED_API_KEY_SECRET_VERSION_ID]; + delete environment[LEGACY_LICENSE_TOKEN_SECRET_VERSION_ID]; + environment.PATH = `${fakeBin}:${process.env.PATH ?? ""}`; + environment.CDK_ENV_CAPTURE = capturePath; + environment.AWS_COMMAND_CAPTURE = awsCapturePath; + environment.NPM_COMMAND_CAPTURE = npmCapturePath; + environment.FRONTEND_CAPTURE = frontendCapturePath; + environment.SECRET_EXPOSURE_CAPTURE = secretExposureCapturePath; + environment.SECRET_INPUT_CAPTURE = secretInputCapturePath; + if (options.callerEnvironment?.apiUrl !== undefined) { + environment[INTELLIGENCE_API_URL] = options.callerEnvironment.apiUrl; + } + if (options.callerEnvironment?.gatewayWsUrl !== undefined) { + environment[INTELLIGENCE_GATEWAY_WS_URL] = + options.callerEnvironment.gatewayWsUrl; + } + + const scriptArguments = [ + ...(options.xtrace === true ? ["-x"] : []), + scriptPath, + ...(options.skipFrontend === false ? [] : ["--skip-frontend"]), + ...(options.skipBackend === true ? ["--skip-backend"] : []), + ]; + const result = spawnSync("/bin/bash", scriptArguments, { + cwd: harnessDirectory, + encoding: "utf8", + env: environment, + }); + const output = `${result.stdout}${result.stderr}`; + const capturedEnvironment = fs.existsSync(capturePath) + ? fs.readFileSync(capturePath, "utf8") + : null; + const capturedLines = capturedEnvironment?.split("\n") ?? []; + const cdkEnvironment = capturedEnvironment + ? `${capturedLines[0] ?? ""}\n${capturedLines[1] ?? ""}\n` + : null; + const secretVersionId = capturedEnvironment + ? (capturedLines[2] ?? null) + : null; + const licenseTokenSecretVersionId = capturedEnvironment + ? (capturedLines[3] ?? null) + : null; + const frontendEnvironment = fs.existsSync(frontendCapturePath) + ? fs.readFileSync(frontendCapturePath, "utf8").split("\n") + : null; + return { + status: result.status, + output, + cdkEnvironment, + secretVersionId, + licenseTokenSecretVersionId, + awsCommands: readAgentCoreHarnessLines(awsCapturePath), + npmCommands: readAgentCoreHarnessLines(npmCapturePath), + frontendStackName: frontendEnvironment?.[0] ?? null, + frontendSecretVersionId: frontendEnvironment?.[1] ?? null, + secretExposureObservations: readAgentCoreHarnessLines( + secretExposureCapturePath, + ), + secretInputObservations: readAgentCoreHarnessLines( + secretInputCapturePath, + ), + }; + } finally { + fs.rmSync(harnessDirectory, { force: true, recursive: true }); + } +}