#!/usr/bin/env bash set -euo pipefail PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TEMPLATE="$PACKAGE_DIR/infra/aws-agentcore-paperclip.yaml" STACK_DESCRIPTION="Paperclip proof-of-concept Amazon Bedrock AgentCore Harness and least-privilege invocation roles." LOCAL_DIR="$PACKAGE_DIR/.paperclip-local" ENV_FILE="$LOCAL_DIR/aws-agentcore.env" ACTIVE_FILE="$LOCAL_DIR/aws-agentcore-lab.pid" ACTION="${1:-}" if [[ -n "$ACTION" ]]; then shift; fi AWS_PROFILE_NAME="${AWS_PROFILE:-default}" AWS_REGION_NAME="${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-1}}" AWS_PROFILE_EXPLICIT=false AWS_REGION_EXPLICIT=false DEPLOYMENT_MODE="development" STACK_NAME="paperclip-agentcore-development" STACK_NAME_EXPLICIT=false ENDPOINT_NAME="paperclip" MODEL_ID="global.anthropic.claude-sonnet-4-6" MARKETPLACE_PRODUCT_ID="prod-ffvjxvh4ltq64" MARKETPLACE_PRODUCT_ID_EXPLICIT=false TRUSTED_PRINCIPAL="" GITHUB_OIDC_PROVIDER_ARN="${PAPERCLIP_AWS_AGENTCORE_GITHUB_OIDC_PROVIDER_ARN:-}" GITHUB_REPOSITORY="paperclipai/paperclip" GITHUB_ENVIRONMENT="runner-e2e-paid" CONTEXT_PREFIX="" DRY_RUN=false FORCE=false YES=false REPLACE_FAILED_STACK=false usage() { printf '%s\n' \ "Usage: aws-agentcore.sh [options]" \ " --aws-profile NAME AWS CLI v2 profile (default: $AWS_PROFILE_NAME)" \ " --region REGION commercial AWS region (default: $AWS_REGION_NAME)" \ " --mode MODE development or private" \ " --stack-name NAME CloudFormation stack name" \ " --model MODEL_ID Bedrock-native model ID" \ " --marketplace-product-id ID exact AWS Marketplace product ID for the model" \ " --principal ARN stable IAM role/user trusted to assume runner role" \ " --github-oidc-provider-arn ARN optional account-local GitHub Actions OIDC provider" \ " --github-repository OWNER/REPO exact repository admitted by OIDC trust (default: $GITHUB_REPOSITORY)" \ " --github-environment NAME exact protected environment admitted by OIDC trust (default: $GITHUB_ENVIRONMENT)" \ " --context-prefix PFX S3 prefix dedicated to this qualified profile" \ " --dry-run validate and print changes without deployment" \ " --replace-failed-stack explicitly replace an owned ROLLBACK_COMPLETE stack" \ " --yes confirm destructive replacement or teardown" \ " --force destroy even when a recorded Lab process is active" } while [[ $# -gt 0 ]]; do case "$1" in --) shift ;; --aws-profile) AWS_PROFILE_NAME="$2"; AWS_PROFILE_EXPLICIT=true; shift 2 ;; --region) AWS_REGION_NAME="$2"; AWS_REGION_EXPLICIT=true; shift 2 ;; --mode) DEPLOYMENT_MODE="$2"; shift 2 ;; --stack-name) STACK_NAME="$2"; STACK_NAME_EXPLICIT=true; shift 2 ;; --model) MODEL_ID="$2"; shift 2 ;; --marketplace-product-id) MARKETPLACE_PRODUCT_ID="$2"; MARKETPLACE_PRODUCT_ID_EXPLICIT=true; shift 2 ;; --principal) TRUSTED_PRINCIPAL="$2"; shift 2 ;; --github-oidc-provider-arn) GITHUB_OIDC_PROVIDER_ARN="$2"; shift 2 ;; --github-repository) GITHUB_REPOSITORY="$2"; shift 2 ;; --github-environment) GITHUB_ENVIRONMENT="$2"; shift 2 ;; --context-prefix) CONTEXT_PREFIX="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; --replace-failed-stack) REPLACE_FAILED_STACK=true; shift ;; --force) FORCE=true; shift ;; --yes) YES=true; shift ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;; esac done if [[ "$ACTION" != "provision" && "$ACTION" != "probe" && "$ACTION" != "lab" && "$ACTION" != "destroy" ]]; then usage >&2 exit 2 fi if [[ "$DEPLOYMENT_MODE" != "development" && "$DEPLOYMENT_MODE" != "private" ]]; then printf 'Mode must be development or private.\n' >&2 exit 2 fi if [[ ! "$AWS_PROFILE_NAME" =~ ^[A-Za-z0-9_.@+-]+$ ]]; then printf 'AWS profile contains unsupported characters.\n' >&2 exit 2 fi if [[ ! "$AWS_REGION_NAME" =~ ^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$ || "$AWS_REGION_NAME" == cn-* || "$AWS_REGION_NAME" == us-gov-* ]]; then printf 'This proof of concept currently supports commercial AWS regions only.\n' >&2 exit 2 fi if [[ ! "$MODEL_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$ ]]; then printf 'Bedrock model ID must not contain ARN, path, glob, or wildcard syntax.\n' >&2 exit 2 fi if [[ "$MODEL_ID" != "global.anthropic.claude-sonnet-4-6" && "$MARKETPLACE_PRODUCT_ID_EXPLICIT" != true ]]; then printf 'A custom --model requires its exact --marketplace-product-id so subscription authority remains model-scoped.\n' >&2 exit 2 fi if [[ ! "$MARKETPLACE_PRODUCT_ID" =~ ^prod-[a-z0-9]+$ ]]; then printf 'Marketplace product ID must use the AWS prod-* form.\n' >&2 exit 2 fi if [[ -n "$GITHUB_OIDC_PROVIDER_ARN" && ! "$GITHUB_OIDC_PROVIDER_ARN" =~ ^arn:aws(-[^:]+)?:iam::[0-9]{12}:oidc-provider/token\.actions\.githubusercontent\.com$ ]]; then printf 'GitHub OIDC provider must be the account-local token.actions.githubusercontent.com provider ARN.\n' >&2 exit 2 fi if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ || ! "$GITHUB_ENVIRONMENT" =~ ^[A-Za-z0-9_.-]+$ ]]; then printf 'GitHub OIDC trust requires exact OWNER/REPO and environment names.\n' >&2 exit 2 fi if [[ -z "$CONTEXT_PREFIX" ]]; then CONTEXT_PREFIX="paperclip/agentcore/$STACK_NAME"; fi if [[ ! "$CONTEXT_PREFIX" =~ ^[a-z0-9][a-z0-9/_-]{1,127}$ || "$CONTEXT_PREFIX" == /* || "$CONTEXT_PREFIX" == */ || "$CONTEXT_PREFIX" == *//* || "/$CONTEXT_PREFIX/" == */./* || "/$CONTEXT_PREFIX/" == */../* ]]; then printf 'Context prefix must be a safe, relative S3 key prefix.\n' >&2 exit 2 fi aws_cli() { aws --profile "$AWS_PROFILE_NAME" --region "$AWS_REGION_NAME" --no-cli-pager "$@" } require_aws() { command -v aws >/dev/null || { printf 'AWS CLI v2 is required.\n' >&2; exit 1; } local version version="$(aws --version 2>&1)" [[ "$version" == aws-cli/2.* ]] || { printf 'AWS CLI v2 is required; found %s\n' "$version" >&2; exit 1; } } json_field() { local key="$1" node -e 'const fs=require("fs");const o=JSON.parse(fs.readFileSync(0,"utf8"));let v=o;for(const k of process.argv[1].split("."))v=v?.[k];if(v!==undefined&&v!==null)process.stdout.write(String(v));' "$key" } stack_tag_value() { local key="$1" node -e 'const fs=require("fs");const o=JSON.parse(fs.readFileSync(0,"utf8"));const tag=o.Stacks?.[0]?.Tags?.find((candidate)=>candidate.Key===process.argv[1]);if(tag?.Value)process.stdout.write(tag.Value);' "$key" } stack_output() { local key="$1" aws_cli cloudformation describe-stacks --stack-name "$STACK_NAME" \ --query "Stacks[0].Outputs[?OutputKey=='$key'].OutputValue | [0]" --output text } wait_for_harness_status() { local harness_id="$1" harness_version="$2" status="" for _ in {1..60}; do status="$(aws_cli bedrock-agentcore-control get-harness --harness-id "$harness_id" --harness-version "$harness_version" --query harness.status --output text 2>/dev/null || true)" if [[ "$status" == "READY" ]]; then return 0; fi if [[ "$status" == "FAILED" || "$status" == "CREATE_FAILED" || "$status" == "UPDATE_FAILED" ]]; then printf 'Harness %s entered terminal status %s.\n' "$harness_id" "$status" >&2 return 1 fi sleep 5 done printf 'Timed out waiting for Harness %s (last status: %s).\n' "$harness_id" "${status:-unknown}" >&2 return 1 } wait_for_memory_status() { local memory_id="$1" status="" for _ in {1..60}; do status="$(aws_cli bedrock-agentcore-control get-memory --memory-id "$memory_id" --query memory.status --output text 2>/dev/null || true)" [[ "$status" == "ACTIVE" ]] && return 0 [[ "$status" == "FAILED" ]] && { printf 'Memory %s entered FAILED.\n' "$memory_id" >&2; return 1; } sleep 5 done printf 'Timed out waiting for Memory %s (last status: %s).\n' "$memory_id" "${status:-unknown}" >&2 return 1 } wait_for_endpoint_status() { local harness_id="$1" endpoint_name="$2" status="" for _ in {1..60}; do status="$(aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$endpoint_name" --query endpoint.status --output text 2>/dev/null || true)" [[ "$status" == "READY" ]] && return 0 [[ "$status" == "FAILED" ]] && { printf 'Harness endpoint %s entered FAILED.\n' "$endpoint_name" >&2; return 1; } sleep 5 done printf 'Timed out waiting for Harness endpoint %s (last status: %s).\n' "$endpoint_name" "${status:-unknown}" >&2 return 1 } normalize_principal() { local arn="$1" if [[ "$arn" =~ ^arn:(aws[^:]*):sts::([0-9]{12}):assumed-role/(.+)/[^/]+$ ]]; then # STS omits an IAM role's path from assumed-role ARNs. This matters for # Identity Center roles, whose canonical ARN lives under # /aws-reserved/sso.amazonaws.com/. Resolve it instead of constructing a # role ARN that may not exist. local role_name canonical_arn role_name="${BASH_REMATCH[3]##*/}" canonical_arn="$(aws_cli iam get-role --role-name "$role_name" --query Role.Arn --output text)" [[ "$canonical_arn" =~ ^arn:(aws[^:]*):iam::[0-9]{12}:role/.+$ ]] || { printf 'Could not resolve the canonical IAM role ARN for %s.\n' "$role_name" >&2 exit 1 } printf '%s\n' "$canonical_arn" elif [[ "$arn" =~ ^arn:(aws[^:]*):iam::[0-9]{12}:(role|user)/.+$ ]]; then printf '%s\n' "$arn" else printf 'The caller ARN cannot be normalized to a stable IAM role/user: %s\n' "$arn" >&2 exit 1 fi } load_local_env() { [[ -f "$ENV_FILE" ]] || { printf 'Missing %s; run aws-agentcore:provision first.\n' "$ENV_FILE" >&2; exit 1; } local requested_profile="$AWS_PROFILE_NAME" requested_region="$AWS_REGION_NAME" requested_stack="$STACK_NAME" # This file is generated locally, mode 0600, and contains nonsecret metadata only. set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a if $AWS_PROFILE_EXPLICIT; then AWS_PROFILE_NAME="$requested_profile"; else AWS_PROFILE_NAME="${AWS_PROFILE:-$requested_profile}"; fi if $AWS_REGION_EXPLICIT; then AWS_REGION_NAME="$requested_region"; else AWS_REGION_NAME="${AWS_REGION:-$requested_region}"; fi if $STACK_NAME_EXPLICIT; then STACK_NAME="$requested_stack"; else STACK_NAME="${PAPERCLIP_AWS_AGENTCORE_STACK_NAME:-$requested_stack}"; fi export AWS_PROFILE="$AWS_PROFILE_NAME" export AWS_REGION="$AWS_REGION_NAME" export AWS_DEFAULT_REGION="$AWS_REGION_NAME" export PAPERCLIP_AWS_AGENTCORE_STACK_NAME="$STACK_NAME" } write_teardown_metadata() { mkdir -p "$LOCAL_DIR" umask 077 local tmp="$ENV_FILE.tmp.$$" printf '%q\n' \ "AWS_PROFILE=$AWS_PROFILE_NAME" \ "AWS_REGION=$AWS_REGION_NAME" \ "AWS_DEFAULT_REGION=$AWS_REGION_NAME" \ "PAPERCLIP_AWS_AGENTCORE_STACK_NAME=$STACK_NAME" >"$tmp" if [[ -n "${AWS_CONFIG_FILE:-}" ]]; then printf 'AWS_CONFIG_FILE=%q\n' "$AWS_CONFIG_FILE" >>"$tmp" fi chmod 600 "$tmp" mv "$tmp" "$ENV_FILE" } assume_runner_role() { local role_arn="$1" local response access secret token if ! response="$(aws_cli sts assume-role --role-arn "$role_arn" --role-session-name paperclip-agentcore-probe --duration-seconds 900 --output json 2>&1)"; then local caller caller="$(aws_cli sts get-caller-identity --query Arn --output text 2>/dev/null || printf '')" printf 'Cannot assume %s. Add this statement to the caller policy for %s:\n' "$role_arn" "$caller" >&2 printf '{"Effect":"Allow","Action":"sts:AssumeRole","Resource":"%s"}\n' "$role_arn" >&2 printf '%s\n' "$response" | sed -E 's/(ASIA|AKIA)[A-Z0-9]+/[REDACTED]/g' >&2 return 1 fi access="$(printf '%s' "$response" | json_field Credentials.AccessKeyId)" secret="$(printf '%s' "$response" | json_field Credentials.SecretAccessKey)" token="$(printf '%s' "$response" | json_field Credentials.SessionToken)" export AWS_ACCESS_KEY_ID="$access" AWS_SECRET_ACCESS_KEY="$secret" AWS_SESSION_TOKEN="$token" unset AWS_PROFILE } probe() { load_local_env local profile_save="$AWS_PROFILE_NAME" harness_json assume_runner_role "$PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN" AWS_PROFILE_NAME="" harness_json="$(aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-harness \ --harness-id "$PAPERCLIP_AWS_AGENTCORE_HARNESS_ID" \ --harness-version "$PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION" --output json)" printf '%s' "$harness_json" | node -e ' const fs = require("fs"); const h = JSON.parse(fs.readFileSync(0, "utf8")).harness; const expectedModel = process.env.PAPERCLIP_AWS_AGENTCORE_MODEL; if (!h || h.model?.bedrockModelConfig?.modelId !== expectedModel) throw new Error("AgentCore model drift"); if (JSON.stringify(h.allowedTools) !== JSON.stringify(["@*/pc_*"])) throw new Error("AgentCore tool allowlist drift"); if (!Array.isArray(h.tools) || h.tools.length !== 0) throw new Error("AgentCore persistent tool drift"); if (!Array.isArray(h.skills) || h.skills.length !== 0) throw new Error("AgentCore skill drift"); ' aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-harness-endpoint \ --harness-id "$PAPERCLIP_AWS_AGENTCORE_HARNESS_ID" \ --endpoint-name "$PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER" >/dev/null aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-memory \ --memory-id "$PAPERCLIP_AWS_AGENTCORE_MEMORY_ID" --view full >/dev/null [[ -n "$PAPERCLIP_AWS_AGENTCORE_CONTEXT_BUCKET" && -n "$PAPERCLIP_AWS_AGENTCORE_CONTEXT_PREFIX" && -n "$PAPERCLIP_AWS_AGENTCORE_CONTEXT_KMS_KEY_ARN" ]] || { printf 'AWS AgentCore profile is missing its qualified S3/KMS runtime context.\n' >&2 exit 1 } unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE_NAME="$profile_save" printf 'AWS AgentCore profile qualified: %s (%s, Harness %s, Memory %s; 90-day retention).\n' \ "$PAPERCLIP_AWS_AGENTCORE_PROFILE_ID" "$AWS_REGION_NAME" \ "$PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION" "$PAPERCLIP_AWS_AGENTCORE_MEMORY_ID" } provision() { require_aws local identity caller_arn account_id partition model_resource_arn foundation_model_id foundation_model_resource_arn identity="$(aws_cli sts get-caller-identity --output json)" caller_arn="$(printf '%s' "$identity" | json_field Arn)" account_id="$(printf '%s' "$identity" | json_field Account)" partition="$(printf '%s' "$caller_arn" | cut -d: -f2)" if [[ -n "$GITHUB_OIDC_PROVIDER_ARN" ]]; then local expected_github_oidc_provider_arn="arn:$partition:iam::$account_id:oidc-provider/token.actions.githubusercontent.com" if [[ "$GITHUB_OIDC_PROVIDER_ARN" != "$expected_github_oidc_provider_arn" ]]; then printf 'GitHub OIDC provider must belong to the current AWS account and partition.\n' >&2 exit 1 fi aws_cli iam get-open-id-connect-provider --open-id-connect-provider-arn "$GITHUB_OIDC_PROVIDER_ARN" >/dev/null fi model_resource_arn="arn:$partition:bedrock:$AWS_REGION_NAME:$account_id:inference-profile/$MODEL_ID" foundation_model_id="$(printf '%s' "$MODEL_ID" | sed -E 's/^(global|us|eu|apac)\.//')" foundation_model_resource_arn="arn:$partition:bedrock:*::foundation-model/$foundation_model_id" if [[ -z "$TRUSTED_PRINCIPAL" ]]; then TRUSTED_PRINCIPAL="$(normalize_principal "$caller_arn")"; fi if [[ "$DEPLOYMENT_MODE" == "private" ]]; then aws_cli bedrock-agentcore-control list-harnesses --max-results 1 >/dev/null || { printf 'Private mode is unavailable because AgentCore Harness is not enabled in %s.\n' "$AWS_REGION_NAME" >&2 exit 1 } local services services="$(aws_cli ec2 describe-vpc-endpoint-services --query 'ServiceNames' --output text)" for service in "com.amazonaws.$AWS_REGION_NAME.ecr.api" "com.amazonaws.$AWS_REGION_NAME.ecr.dkr" "com.amazonaws.$AWS_REGION_NAME.s3" "com.amazonaws.$AWS_REGION_NAME.bedrock-runtime"; do [[ " $services " == *" $service "* ]] || { printf 'Private mode is unavailable: missing VPC endpoint service %s\n' "$service" >&2; exit 1; } done fi aws_cli cloudformation validate-template --template-body "file://$TEMPLATE" >/dev/null printf 'Stack: %s\nRegion: %s\nMode: %s\nModel: %s\nTrusted principal: %s\n' \ "$STACK_NAME" "$AWS_REGION_NAME" "$DEPLOYMENT_MODE" "$MODEL_ID" "$TRUSTED_PRINCIPAL" printf 'Expected charges: Bedrock model tokens, AgentCore Runtime active time, Memory storage/requests, and (private mode) VPC endpoints.\n' if $DRY_RUN; then printf 'Dry run complete; the template is syntactically valid and no resources were changed.\n' return fi local existing_stack="" existing_status="" if existing_stack="$(aws_cli cloudformation describe-stacks --stack-name "$STACK_NAME" --output json 2>&1)"; then local existing_description existing_owned existing_environment existing_cost_center existing_status="$(printf '%s' "$existing_stack" | json_field Stacks.0.StackStatus)" existing_description="$(printf '%s' "$existing_stack" | json_field Stacks.0.Description)" existing_owned="$(printf '%s' "$existing_stack" | stack_tag_value paperclip:owned)" existing_environment="$(printf '%s' "$existing_stack" | stack_tag_value paperclip:environment)" existing_cost_center="$(printf '%s' "$existing_stack" | stack_tag_value paperclip:cost-center)" if [[ -z "$existing_status" || "$existing_description" != "$STACK_DESCRIPTION" || "$existing_owned" != "true" || "$existing_environment" != "development" || "$existing_cost_center" != "runner-lab" ]]; then printf 'Refusing to modify stack %s: Paperclip ownership tags or template provenance do not match. Inspect or remove it manually, or choose another --stack-name.\n' "$STACK_NAME" >&2 exit 1 fi elif [[ "$existing_stack" == *"(ValidationError)"* && "$existing_stack" == *"does not exist"* ]]; then existing_stack="" else printf 'Unable to verify whether stack %s exists and is owned by Paperclip; refusing to provision.\n' "$STACK_NAME" >&2 exit 1 fi if [[ "$existing_status" == "ROLLBACK_COMPLETE" ]]; then if ! $REPLACE_FAILED_STACK; then printf 'Stack %s is ROLLBACK_COMPLETE. Re-run with --replace-failed-stack to explicitly authorize deleting this verified Paperclip stack.\n' "$STACK_NAME" >&2 exit 1 fi if ! $YES; then printf 'Delete verified Paperclip stack %s before retrying provisioning? [y/N] ' "$STACK_NAME" read -r answer || true [[ "$answer" == y || "$answer" == Y ]] || { printf 'Cancelled.\n'; return; } fi printf 'Removing verified failed Paperclip stack %s before retrying provisioning.\n' "$STACK_NAME" aws_cli cloudformation delete-stack --stack-name "$STACK_NAME" aws_cli cloudformation wait stack-delete-complete --stack-name "$STACK_NAME" fi aws_cli cloudformation deploy \ --template-file "$TEMPLATE" \ --stack-name "$STACK_NAME" \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides \ "EnvironmentName=development" "DeploymentMode=$DEPLOYMENT_MODE" \ "HarnessEndpointName=$ENDPOINT_NAME" \ "ContextPrefix=$CONTEXT_PREFIX" \ "TrustedRunnerPrincipalArn=$TRUSTED_PRINCIPAL" \ "GitHubOidcProviderArn=$GITHUB_OIDC_PROVIDER_ARN" \ "GitHubRepository=$GITHUB_REPOSITORY" \ "GitHubEnvironment=$GITHUB_ENVIRONMENT" \ "BedrockModelId=$MODEL_ID" \ "BedrockModelResourceArn=$model_resource_arn" \ "BedrockFoundationModelResourceArn=$foundation_model_resource_arn" \ "BedrockMarketplaceProductId=$MARKETPLACE_PRODUCT_ID" \ --tags paperclip:owned=true paperclip:environment=development paperclip:cost-center=runner-lab # CloudFormation has created the billable resources at this point. Persist # the nonsecret coordinates needed by `destroy` before any qualification # API call can fail, then replace this checkpoint with the full qualified # profile only after every resource is ready. write_teardown_metadata local harness_id harness_arn harness_version runtime_arn memory_id memory_arn role_arn context_bucket context_prefix context_kms_key_arn qualification_revision harness_id="$(stack_output HarnessId)" harness_arn="$(stack_output HarnessArn)" harness_version="$(stack_output HarnessVersion)" runtime_arn="$(stack_output AgentRuntimeArn)" memory_id="$(stack_output MemoryId)" memory_arn="$(stack_output MemoryArn)" role_arn="$(stack_output RunnerInvocationRoleArn)" context_bucket="$(stack_output ContextBucketName)" context_prefix="$(stack_output ContextPrefix)" context_kms_key_arn="$(stack_output ContextKmsKeyArn)" qualification_revision="$(stack_output QualificationRevision)" wait_for_harness_status "$harness_id" "$harness_version" wait_for_memory_status "$memory_id" if aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" >/dev/null 2>&1; then aws_cli bedrock-agentcore-control update-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" \ --target-version "$harness_version" --description "Paperclip pinned Harness endpoint" \ --client-token "paperclip-update-$harness_id-$harness_version" >/dev/null else aws_cli bedrock-agentcore-control create-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" \ --target-version "$harness_version" --description "Paperclip pinned Harness endpoint" \ --client-token "paperclip-create-$harness_id-$harness_version" \ --tags paperclip:owned=true,paperclip:environment=development >/dev/null fi wait_for_endpoint_status "$harness_id" "$ENDPOINT_NAME" local endpoint_json endpoint_arn endpoint_json="$(aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" --output json)" endpoint_arn="$(printf '%s' "$endpoint_json" | node -e 'const fs=require("fs");const o=JSON.parse(fs.readFileSync(0,"utf8"));process.stdout.write(o.endpoint?.arn||o.harnessEndpointArn||o.endpointArn||o.arn||"")')" [[ -n "$endpoint_arn" ]] || endpoint_arn="$harness_arn/endpoint/$ENDPOINT_NAME" mkdir -p "$LOCAL_DIR" umask 077 local tmp="$ENV_FILE.tmp.$$" printf '%q\n' \ "AWS_PROFILE=$AWS_PROFILE_NAME" \ "AWS_REGION=$AWS_REGION_NAME" \ "AWS_DEFAULT_REGION=$AWS_REGION_NAME" \ "PAPERCLIP_AWS_AGENTCORE_STACK_NAME=$STACK_NAME" \ "PAPERCLIP_AWS_AGENTCORE_PROFILE_ID=$STACK_NAME" \ "PAPERCLIP_AWS_AGENTCORE_ACCOUNT_ID=$account_id" \ "PAPERCLIP_AWS_AGENTCORE_HARNESS_ARN=$harness_arn" \ "PAPERCLIP_AWS_AGENTCORE_HARNESS_ID=$harness_id" \ "PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION=$harness_version" \ "PAPERCLIP_AWS_AGENTCORE_ENDPOINT_ARN=$endpoint_arn" \ "PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER=$ENDPOINT_NAME" \ "PAPERCLIP_AWS_AGENTCORE_RUNTIME_ARN=$runtime_arn" \ "PAPERCLIP_AWS_AGENTCORE_MEMORY_ARN=$memory_arn" \ "PAPERCLIP_AWS_AGENTCORE_MEMORY_ID=$memory_id" \ "PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN=$role_arn" \ "PAPERCLIP_AWS_AGENTCORE_EXECUTION_ROLE_ARN=$role_arn" \ "PAPERCLIP_AWS_AGENTCORE_CONTEXT_BUCKET=$context_bucket" \ "PAPERCLIP_AWS_AGENTCORE_CONTEXT_PREFIX=$context_prefix" \ "PAPERCLIP_AWS_AGENTCORE_CONTEXT_KMS_KEY_ARN=$context_kms_key_arn" \ "PAPERCLIP_AWS_AGENTCORE_MODEL=$MODEL_ID" \ "PAPERCLIP_AWS_AGENTCORE_QUALIFICATION_REVISION=$qualification_revision" \ "PAPERCLIP_AWS_AGENTCORE_EVENT_EXPIRY_DAYS=90" >"$tmp" if [[ -n "${AWS_CONFIG_FILE:-}" ]]; then printf 'AWS_CONFIG_FILE=%q\n' "$AWS_CONFIG_FILE" >>"$tmp" fi chmod 600 "$tmp" mv "$tmp" "$ENV_FILE" probe printf 'Wrote nonsecret local profile metadata to %s (0600).\n' "$ENV_FILE" } lab() { load_local_env if [[ -f "$ACTIVE_FILE" ]] && kill -0 "$(cat "$ACTIVE_FILE")" 2>/dev/null; then printf 'Runner Lab is already active as PID %s.\n' "$(cat "$ACTIVE_FILE")" >&2 exit 1 fi printf '%s\n' "$$" >"$ACTIVE_FILE" chmod 600 "$ACTIVE_FILE" trap 'rm -f "$ACTIVE_FILE"' EXIT INT TERM cd "$PACKAGE_DIR" pnpm console:issue-thread } destroy() { require_aws load_local_env if [[ -f "$ACTIVE_FILE" ]] && kill -0 "$(cat "$ACTIVE_FILE")" 2>/dev/null && ! $FORCE; then printf 'Runner Lab PID %s is active; stop it or pass --force.\n' "$(cat "$ACTIVE_FILE")" >&2 exit 1 fi if ! $YES; then printf 'Delete the pinned AgentCore endpoint and CloudFormation stack %s? [y/N] ' "$STACK_NAME" read -r answer [[ "$answer" == y || "$answer" == Y ]] || { printf 'Cancelled.\n'; return; } fi local harness_id context_bucket context_prefix profile_save harness_id="$(stack_output HarnessId)" context_bucket="$(stack_output ContextBucketName)" context_prefix="$(stack_output ContextPrefix)" aws_cli bedrock-agentcore-control delete-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" \ --client-token "paperclip-delete-$harness_id-$ENDPOINT_NAME" >/dev/null 2>&1 || true profile_save="$AWS_PROFILE_NAME" assume_runner_role "$(stack_output RunnerInvocationRoleArn)" aws --region "$AWS_REGION_NAME" --no-cli-pager s3 rm "s3://$context_bucket/$context_prefix/assets/" --recursive >/dev/null unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE_NAME="$profile_save" aws_cli cloudformation delete-stack --stack-name "$STACK_NAME" aws_cli cloudformation wait stack-delete-complete --stack-name "$STACK_NAME" rm -f "$ENV_FILE" "$ACTIVE_FILE" printf 'Deleted AgentCore endpoint and stack. Local profile metadata was removed.\n' } case "$ACTION" in provision) provision ;; probe) require_aws; probe ;; lab) lab ;; destroy) destroy ;; esac