mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
feat(integrations): align managed Intelligence starters
This commit is contained in:
committed by
Maximiliano Korp
parent
65bd05e368
commit
231ec47c4f
@@ -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=<instance>"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
# Stack: <stack_name_base>-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
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
# Stack: <stack_name_base>-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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,7 +61,7 @@ function CopilotChatContent() {
|
||||
*/
|
||||
<CopilotChatConfigurationProvider agentId={COPILOTKIT_AGENT_ID}>
|
||||
<div className={styles.layout}>
|
||||
{/* 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. */}
|
||||
<CopilotThreadsDrawer agentId={COPILOTKIT_AGENT_ID} />
|
||||
<div className={styles.mainPanel}>
|
||||
<div className="h-full flex flex-row">
|
||||
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<div className="h-full bg-[#f5f7fb]">
|
||||
<CopilotKitShell
|
||||
config={config as ResolvedAwsExportsConfig}
|
||||
accessToken={accessToken}
|
||||
idToken={idToken}
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
testMatch: ["<rootDir>/test/**/*.test.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<Record<string, string | undefined>> | null;
|
||||
readonly multiValueHeaders?: Readonly<
|
||||
Record<string, readonly string[] | undefined>
|
||||
> | null;
|
||||
readonly requestContext?: {
|
||||
readonly authorizer?: {
|
||||
readonly claims?: Readonly<Record<string, unknown>> | 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" };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+30
-43
@@ -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<string, HttpAgent> {
|
||||
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<string>();
|
||||
|
||||
@@ -67,7 +57,6 @@ export class AgentCoreRunner extends InMemoryAgentRunner {
|
||||
request: Parameters<InMemoryAgentRunner["connect"]>[0],
|
||||
): ReturnType<InMemoryAgentRunner["connect"]> {
|
||||
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<InMemoryAgentRunner["connect"]>;
|
||||
}
|
||||
|
||||
// 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" });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user