feat(integrations): align managed Intelligence starters

This commit is contained in:
Mike Ryan
2026-07-27 10:50:34 -07:00
committed by Maximiliano Korp
parent 65bd05e368
commit 231ec47c4f
23 changed files with 5213 additions and 538 deletions
@@ -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
+49 -5
View File
@@ -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 ~1015 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 ~1015 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);
@@ -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