diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/deploy_runtime.yaml b/.github/workflows/deploy_runtime.yaml new file mode 100644 index 0000000..2e965c6 --- /dev/null +++ b/.github/workflows/deploy_runtime.yaml @@ -0,0 +1,90 @@ +# Deploys the shared backend (runtime/) to Cloud Run on every change to it on main. +# One-time setup of Google Cloud: runtime/deploy/setup_gcp.sh, which also lists the +# GitHub secrets this workflow needs. +name: deploy_runtime + +on: + push: + branches: [main] + paths: + - "runtime/**" + - ".github/workflows/deploy_runtime.yaml" + workflow_dispatch: + +# Two deploys at once would race on the same service. +concurrency: + group: deploy_runtime + cancel-in-progress: false + +permissions: + contents: read + id-token: write # lets the job sign in to Google Cloud without a key file + +env: + PROJECT_ID: gen-lang-client-0563181971 + REGION: us-central1 + SERVICE: calaverita-runtime + REGISTRY_REPOSITORY: calaverita + RUNTIME_SERVICE_ACCOUNT: calaverita-runtime@gen-lang-client-0563181971.iam.gserviceaccount.com + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Sign in to Google Cloud + id: auth + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + token_format: access_token + + - name: Sign in to Artifact Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGION }}-docker.pkg.dev + username: oauth2accesstoken + password: ${{ steps.auth.outputs.access_token }} + + - name: Build and push the image + id: image + env: + IMAGE: ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REGISTRY_REPOSITORY }}/runtime:${{ github.sha }} + run: | + docker build --tag "$IMAGE" runtime + docker push "$IMAGE" + echo "name=$IMAGE" >> "$GITHUB_OUTPUT" + + # The key and the password stay in Secret Manager: Cloud Run hands them to the + # revision as environment variables, read as the runtime service account. + - name: Deploy to Cloud Run + id: deploy + uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2 + with: + project_id: ${{ env.PROJECT_ID }} + region: ${{ env.REGION }} + service: ${{ env.SERVICE }} + image: ${{ steps.image.outputs.name }} + secrets: | + ANTHROPIC_API_KEY=anthropic-api-key:latest + WORKSHOP_CODE=workshop-code:latest + flags: >- + --service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }} + --max-instances=1 + --cpu-boost + --memory=512Mi + --timeout=900 + + # The password gate is the point of this deployment, so check it is on. + - name: Smoke test + env: + BASE: ${{ steps.deploy.outputs.url }}/api/copilotkit + run: | + curl --fail --silent --show-error --retry 5 --retry-delay 5 --retry-all-errors "$BASE/info" | jq -e '.agents.default' \ + || { echo "If this is a 403, the service is not public yet: run runtime/deploy/setup_gcp.sh again."; exit 1; } + status=$(curl --silent --output /dev/null --write-out '%{http_code}' --request POST "$BASE/agent/default/run" --data '{}') + test "$status" = 401 || { echo "Expected 401 without the workshop code, got $status"; exit 1; } + echo "Deployed: $BASE" diff --git a/.github/workflows/runtime_ci.yaml b/.github/workflows/runtime_ci.yaml new file mode 100644 index 0000000..87512f3 --- /dev/null +++ b/.github/workflows/runtime_ci.yaml @@ -0,0 +1,37 @@ +# Checks a change to the shared backend before it can reach main. +name: runtime_ci + +on: + pull_request: + paths: + - "runtime/**" + - ".github/workflows/runtime_ci.yaml" + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: runtime/package-lock.json + + - name: Typecheck + working-directory: runtime + run: | + npm ci --no-audit --no-fund + npm run typecheck + + - name: Test the password gate + working-directory: runtime + run: npm test + + - name: Build the image + run: docker build --tag calaverita-runtime runtime diff --git a/.github/workflows/warm_runtime.yaml b/.github/workflows/warm_runtime.yaml new file mode 100644 index 0000000..228a4fa --- /dev/null +++ b/.github/workflows/warm_runtime.yaml @@ -0,0 +1,31 @@ +# Keeps the shared backend awake for a while, so the first participants don't wait +# for a cold start. Run it by hand from the Actions tab shortly before the event. +name: warm_runtime + +on: + workflow_dispatch: + inputs: + url: + description: Runtime URL + default: https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit + minutes: + description: How long to keep it warm + default: "120" + +permissions: {} + +jobs: + warm: + runs-on: ubuntu-latest + timeout-minutes: 190 + steps: + - name: Ping /info every 4 minutes + env: + URL: ${{ inputs.url }} + MINUTES: ${{ inputs.minutes }} + run: | + end=$(( $(date +%s) + MINUTES * 60 )) + while [ "$(date +%s)" -lt "$end" ]; do + curl --silent --show-error --max-time 60 --output /dev/null --write-out '%{http_code}\n' "$URL/info" || true + sleep 240 + done diff --git a/01-shared-state/apps/calaverita_app/lib/app/app.dart b/01-shared-state/apps/calaverita_app/lib/app/app.dart index 7a00fc6..b1f379e 100644 --- a/01-shared-state/apps/calaverita_app/lib/app/app.dart +++ b/01-shared-state/apps/calaverita_app/lib/app/app.dart @@ -22,6 +22,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) { return CopilotKit( runtimeUrl: AppConfig.runtimeUrl, + headers: AppConfig.headers, child: MaterialApp( title: 'Calaverita Generator', debugShowCheckedModeBanner: false, diff --git a/01-shared-state/apps/calaverita_app/lib/app/app_config.dart b/01-shared-state/apps/calaverita_app/lib/app/app_config.dart index a202800..cbe99d5 100644 --- a/01-shared-state/apps/calaverita_app/lib/app/app_config.dart +++ b/01-shared-state/apps/calaverita_app/lib/app/app_config.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + /// The one place that says where the agent lives. /// /// To use the shared hosted backend, change [defaultRuntimeUrl] (or run with @@ -24,4 +26,31 @@ class AppConfig { 'AGENT_ID', defaultValue: 'default', ); + + /// The password of the shared backend, when it asks for one. + /// + /// Run with `--dart-define=WORKSHOP_CODE=`. A local runtime asks for + /// nothing, so leave it out there. + static const workshopCode = String.fromEnvironment('WORKSHOP_CODE'); + + /// A random id for this run of the app, so a server can tell sessions apart. + static final String sessionId = _randomId(); + + /// The headers sent with every run. + /// + /// Custom names on purpose: a runtime answers the browser's preflight with + /// `Access-Control-Allow-Headers: *`, which covers them but not + /// `Authorization`. A server that does not know them ignores them. + static Future> headers() async => { + 'X-Workshop-Session': sessionId, + if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode, + }; + + static String _randomId() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + } } diff --git a/02-generative-ui/apps/calaverita_app/lib/app/app.dart b/02-generative-ui/apps/calaverita_app/lib/app/app.dart index 09733c8..f073c20 100644 --- a/02-generative-ui/apps/calaverita_app/lib/app/app.dart +++ b/02-generative-ui/apps/calaverita_app/lib/app/app.dart @@ -22,6 +22,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) { return CopilotKit( runtimeUrl: AppConfig.runtimeUrl, + headers: AppConfig.headers, child: MaterialApp( title: 'Calaverita Generator', debugShowCheckedModeBanner: false, diff --git a/02-generative-ui/apps/calaverita_app/lib/app/app_config.dart b/02-generative-ui/apps/calaverita_app/lib/app/app_config.dart index a202800..cbe99d5 100644 --- a/02-generative-ui/apps/calaverita_app/lib/app/app_config.dart +++ b/02-generative-ui/apps/calaverita_app/lib/app/app_config.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + /// The one place that says where the agent lives. /// /// To use the shared hosted backend, change [defaultRuntimeUrl] (or run with @@ -24,4 +26,31 @@ class AppConfig { 'AGENT_ID', defaultValue: 'default', ); + + /// The password of the shared backend, when it asks for one. + /// + /// Run with `--dart-define=WORKSHOP_CODE=`. A local runtime asks for + /// nothing, so leave it out there. + static const workshopCode = String.fromEnvironment('WORKSHOP_CODE'); + + /// A random id for this run of the app, so a server can tell sessions apart. + static final String sessionId = _randomId(); + + /// The headers sent with every run. + /// + /// Custom names on purpose: a runtime answers the browser's preflight with + /// `Access-Control-Allow-Headers: *`, which covers them but not + /// `Authorization`. A server that does not know them ignores them. + static Future> headers() async => { + 'X-Workshop-Session': sessionId, + if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode, + }; + + static String _randomId() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + } } diff --git a/03-custom-server/apps/calaverita_app/lib/app/app.dart b/03-custom-server/apps/calaverita_app/lib/app/app.dart index 09733c8..f073c20 100644 --- a/03-custom-server/apps/calaverita_app/lib/app/app.dart +++ b/03-custom-server/apps/calaverita_app/lib/app/app.dart @@ -22,6 +22,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) { return CopilotKit( runtimeUrl: AppConfig.runtimeUrl, + headers: AppConfig.headers, child: MaterialApp( title: 'Calaverita Generator', debugShowCheckedModeBanner: false, diff --git a/03-custom-server/apps/calaverita_app/lib/app/app_config.dart b/03-custom-server/apps/calaverita_app/lib/app/app_config.dart index 7823881..18b76a2 100644 --- a/03-custom-server/apps/calaverita_app/lib/app/app_config.dart +++ b/03-custom-server/apps/calaverita_app/lib/app/app_config.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + /// The one place that says where the agent lives. /// /// To use a hosted server, change [defaultRuntimeUrl] (or run with @@ -24,4 +26,31 @@ class AppConfig { 'AGENT_ID', defaultValue: 'calaverita', ); + + /// The password of a hosted server, when it asks for one. + /// + /// Run with `--dart-define=WORKSHOP_CODE=`. A local server asks for + /// nothing, so leave it out there. + static const workshopCode = String.fromEnvironment('WORKSHOP_CODE'); + + /// A random id for this run of the app, so a server can tell sessions apart. + static final String sessionId = _randomId(); + + /// The headers sent with every run. + /// + /// Custom names on purpose: a runtime answers the browser's preflight with + /// `Access-Control-Allow-Headers: *`, which covers them but not + /// `Authorization`. A server that does not know them ignores them. + static Future> headers() async => { + 'X-Workshop-Session': sessionId, + if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode, + }; + + static String _randomId() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + } } diff --git a/03-custom-server/server/src/index.ts b/03-custom-server/server/src/index.ts index 865afe8..f3c073a 100644 --- a/03-custom-server/server/src/index.ts +++ b/03-custom-server/server/src/index.ts @@ -17,7 +17,7 @@ const runtime = new CopilotRuntime({ }); const app = new Hono(); -app.use("*", cors({ origin: "*", allowHeaders: ["authorization", "content-type"] })); +app.use("*", cors({ origin: "*", allowHeaders: ["authorization", "content-type", "x-workshop-code", "x-workshop-session"] })); app.get("/health", (c) => c.text("ok")); app.route("/", createCopilotHonoHandler({ runtime, basePath: "/api/copilotkit" })); diff --git a/README.md b/README.md index c075495..ad60598 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,10 @@ node tool/setup.mjs It installs Flutter 3.47.0 through FVM, checks your machine, asks which step to run and which backend to use, and starts the app in Chrome. The backend is one of: -- **The shared backend:** nothing to set up. +- **The shared backend:** the one we host for the workshop, at + `https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit`. + Nothing to set up except the workshop password, which you get at the workshop. + It asks for it (hidden as you type), or pass `--code `. - **Your own runtime:** it asks for your Anthropic API key (hidden as you type) and starts the runtime in `runtime/` for you. - **A custom backend:** any CopilotKit runtime you have a URL for, for example one @@ -187,6 +190,34 @@ The runtime URL is set in **one place per snapshot**: `AppConfig.defaultRuntimeUrl`, or run with `--dart-define=RUNTIME_URL=`. +The shared backend (`https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit`) +also asks for a password. The app sends it, and a random id +of the run, as the `X-Workshop-Code` and `X-Workshop-Session` headers on every +request: + +```sh +fvm flutter run -d chrome --dart-define=RUNTIME_URL= --dart-define=WORKSHOP_CODE= +``` + +`--dart-define` compiles the password into the build, so **a build made with +`WORKSHOP_CODE` contains the password**: anyone who has that web build can read +it. Run it locally, and never host a build made with the code. + +A runtime on your machine ignores both headers, so leave `WORKSHOP_CODE` out +there. To host the shared backend, deploy `runtime/` and set `WORKSHOP_CODE` and +`ANTHROPIC_API_KEY` on the host. The runtime then answers `401` without the +password. `GET /info` and the CORS preflight stay open. + +The runtime also logs one line per run with the session id, and answers `429` +once a session passes `MAX_RUNS_PER_SESSION` runs (default 100) or all sessions +pass `MAX_RUNS_TOTAL` (default 5000). Change `WORKSHOP_CODE` after the event, +and set a spend limit on the Anthropic key: `--max-instances=1` limits +concurrency, not cost. + +A hosted backend that has been idle takes a few seconds to wake up, so the +first check or message can be slow. To warm it before the event, run the +`warm_runtime` workflow from the Actions tab. + ## How the code is organized Each step is a Dart workspace of small packages: @@ -225,6 +256,8 @@ packages. - **The runtime starts but every message fails**: the model or the key is wrong. Send a run straight to the runtime and read the `RUN_ERROR` event (see the [SDK example README](https://github.com/VGVentures/copilotkit_flutter_sdk/blob/main/example/README.md#troubleshooting)). +- **"The copilot could not answer" on the shared backend**: the password is + missing or wrong (`401`). - **`pub get` can't fetch the SDK**: you need access to the SDK repository. ## License diff --git a/runtime/.dockerignore b/runtime/.dockerignore new file mode 100644 index 0000000..dcbfcff --- /dev/null +++ b/runtime/.dockerignore @@ -0,0 +1,6 @@ +# The key lives on the host, never in an image. +.env +node_modules +deploy +Dockerfile +.dockerignore diff --git a/runtime/.env.example b/runtime/.env.example index 26df643..1384829 100644 --- a/runtime/.env.example +++ b/runtime/.env.example @@ -1,3 +1,10 @@ # Copy this file to .env (git-ignored) and fill in your key. COPILOTKIT_MODEL=anthropic:claude-sonnet-5-5 ANTHROPIC_API_KEY= + +# Only for a shared, hosted runtime: the password the participants send. Leave it +# out on your machine. +# WORKSHOP_CODE= +# Caps on the runs the shared runtime accepts (the defaults): +# MAX_RUNS_PER_SESSION=100 +# MAX_RUNS_TOTAL=5000 diff --git a/runtime/.gcloudignore b/runtime/.gcloudignore new file mode 100644 index 0000000..7d84e75 --- /dev/null +++ b/runtime/.gcloudignore @@ -0,0 +1,8 @@ +# What `gcloud run deploy --source runtime` uploads. The key lives on the host, +# never in the upload, and the image installs its own node_modules. +* +!Dockerfile +!package.json +!package-lock.json +!server.ts +!env.ts diff --git a/runtime/Dockerfile b/runtime/Dockerfile new file mode 100644 index 0000000..c95eaa8 --- /dev/null +++ b/runtime/Dockerfile @@ -0,0 +1,16 @@ +# The image of the shared backend. Build it from this folder: +# docker build -t calaverita-runtime runtime +FROM node:22-slim + +WORKDIR /app +ENV NODE_ENV=production + +# `npm start` runs the TypeScript with tsx, a dev dependency, so install all of them. +COPY package.json package-lock.json ./ +RUN npm ci --include=dev --no-audit --no-fund + +COPY server.ts env.ts gate.ts ./ + +USER node +# Cloud Run sets PORT; server.ts reads it and listens on 0.0.0.0. +CMD ["npm", "start", "--silent"] diff --git a/runtime/deploy/setup_gcp.sh b/runtime/deploy/setup_gcp.sh new file mode 100755 index 0000000..7bfd281 --- /dev/null +++ b/runtime/deploy/setup_gcp.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# One-time Google Cloud setup for the shared backend. Safe to run again. +# +# gcloud auth login +# bash runtime/deploy/setup_gcp.sh +# +# The Anthropic key and the workshop password live in Secret Manager, and Cloud Run +# reads them as the runtime service account. Pass them as ANTHROPIC_API_KEY and +# WORKSHOP_CODE in the environment, or let the script ask (the input is hidden). +# To change one later, run the script again with the new value. +# +# Run it again after the first deploy: only then does the service exist, and the +# script makes it public (see step 6). +set -euo pipefail + +PROJECT="${PROJECT:-gen-lang-client-0563181971}" +REGION="${REGION:-us-central1}" +GITHUB_REPO="${GITHUB_REPO:-VeryGoodOpenSource/copilotkit_flutter_workshop}" +SERVICE="calaverita-runtime" +REGISTRY="calaverita" +POOL="github" +PROVIDER="github-oidc" +DEPLOYER="github-deployer" +RUNTIME_SA="calaverita-runtime" + +gc() { gcloud --project "$PROJECT" "$@"; } +step() { printf '\n\033[1m%s\033[0m\n' "$*"; } + +step "1. Enabling the APIs" +gc services enable \ + run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com \ + iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \ + cloudresourcemanager.googleapis.com + +PROJECT_NUMBER="$(gc projects describe "$PROJECT" --format='value(projectNumber)')" +DEPLOYER_EMAIL="$DEPLOYER@$PROJECT.iam.gserviceaccount.com" +RUNTIME_EMAIL="$RUNTIME_SA@$PROJECT.iam.gserviceaccount.com" + +step "2. Artifact Registry repository for the image" +gc artifacts repositories describe "$REGISTRY" --location "$REGION" >/dev/null 2>&1 \ + || gc artifacts repositories create "$REGISTRY" --repository-format docker --location "$REGION" \ + --description "Calaverita workshop runtime" + +step "3. Service accounts" +# The one the runtime runs as. It has no roles on purpose: the runtime only calls +# Anthropic, and the default account would carry the project's Editor role. +gc iam service-accounts describe "$RUNTIME_EMAIL" >/dev/null 2>&1 \ + || gc iam service-accounts create "$RUNTIME_SA" --display-name "Calaverita runtime" +# The one GitHub Actions deploys with. +gc iam service-accounts describe "$DEPLOYER_EMAIL" >/dev/null 2>&1 \ + || gc iam service-accounts create "$DEPLOYER" --display-name "GitHub deployer" + +step "4. What the deployer may do" +gc artifacts repositories add-iam-policy-binding "$REGISTRY" --location "$REGION" \ + --member "serviceAccount:$DEPLOYER_EMAIL" --role roles/artifactregistry.writer >/dev/null +# run.developer deploys revisions but cannot change who may call the service (step 6). +gc projects add-iam-policy-binding "$PROJECT" \ + --member "serviceAccount:$DEPLOYER_EMAIL" --role roles/run.developer --condition=None >/dev/null +# Earlier versions of this script gave the deployer run.admin; take it back. +gc projects remove-iam-policy-binding "$PROJECT" \ + --member "serviceAccount:$DEPLOYER_EMAIL" --role roles/run.admin --condition=None >/dev/null 2>&1 || true +gc iam service-accounts add-iam-policy-binding "$RUNTIME_EMAIL" \ + --member "serviceAccount:$DEPLOYER_EMAIL" --role roles/iam.serviceAccountUser >/dev/null + +step "5. Workload Identity Federation: GitHub signs in without a key file" +gc iam workload-identity-pools describe "$POOL" --location global >/dev/null 2>&1 \ + || gc iam workload-identity-pools create "$POOL" --location global --display-name "GitHub Actions" +gc iam workload-identity-pools providers describe "$PROVIDER" --workload-identity-pool "$POOL" --location global >/dev/null 2>&1 \ + || gc iam workload-identity-pools providers create-oidc "$PROVIDER" \ + --workload-identity-pool "$POOL" --location global \ + --issuer-uri "https://token.actions.githubusercontent.com" \ + --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \ + --attribute-condition "assertion.repository == '$GITHUB_REPO' && assertion.ref == 'refs/heads/main'" +gc iam service-accounts add-iam-policy-binding "$DEPLOYER_EMAIL" \ + --role roles/iam.workloadIdentityUser \ + --member "principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL/attribute.repository/$GITHUB_REPO" >/dev/null + +PROVIDER_NAME="$(gc iam workload-identity-pools providers describe "$PROVIDER" --workload-identity-pool "$POOL" --location global --format='value(name)')" + +step "6. Secrets in Secret Manager" +# Create the secret, let the runtime service account read it, and add a version when given a value. +store_secret() { + local name="$1" value="${2:-}" + gc secrets describe "$name" >/dev/null 2>&1 || gc secrets create "$name" --replication-policy automatic >/dev/null + gc secrets add-iam-policy-binding "$name" \ + --member "serviceAccount:$RUNTIME_EMAIL" --role roles/secretmanager.secretAccessor >/dev/null + if [ -z "$value" ] && ! gc secrets versions list "$name" --limit 1 --format='value(name)' | grep -q .; then + read -rsp "Value of $name (hidden): " value; echo + fi + if [ -n "$value" ]; then printf '%s' "$value" | gc secrets versions add "$name" --data-file=- >/dev/null; fi +} +store_secret anthropic-api-key "${ANTHROPIC_API_KEY:-}" +store_secret workshop-code "${WORKSHOP_CODE:-}" + +step "7. Who may call the service" +# The deployer cannot do this itself, so it happens here, once the service exists. +if gc run services describe "$SERVICE" --region "$REGION" >/dev/null 2>&1; then + gc run services add-iam-policy-binding "$SERVICE" --region "$REGION" \ + --member allUsers --role roles/run.invoker >/dev/null + echo "The service is public (the workshop password still guards it)." +else + echo "The service does not exist yet. Deploy once, then run this script again to make it public." +fi + +step "Done. Add these two secrets to GitHub (Settings > Secrets and variables > Actions):" +cat < = {}) => + fetch(`${base}${path}`, { method, headers }).then((r) => r.status); + +describe("createGate", () => { + before(async () => { + const gate = createGate((_req, res) => res.writeHead(200).end("ok"), { + code: "secret", + maxRunsPerSession: 2, + maxRunsTotal: 3, + log: (line) => lines.push(line), + }); + server = createServer(gate); + await new Promise((resolve) => server.listen(0, resolve)); + base = `http://localhost:${(server.address() as AddressInfo).port}/api/copilotkit`; + }); + after(() => server.close()); + + it("keeps /info and the preflight open", async () => { + assert.equal(await call("/info", "GET"), 200); + assert.equal(await call("/agent/default/run", "OPTIONS"), 200); + }); + + it("refuses a missing or wrong code", async () => { + assert.equal(await call("/agent/default/run", "POST"), 401); + assert.equal(await call("/agent/default/run", "POST", { "x-workshop-code": "nope" }), 401); + assert.equal(await call("/agent/default/run", "POST", { "x-workshop-code": "secre" }), 401); + }); + + it("refuses a stop request without the code", async () => { + assert.equal(await call("/agent/default/stop/thread", "POST"), 401); + assert.equal(await call("/agent/default/stop/thread", "POST", { "x-workshop-code": "secret" }), 200); + }); + + it("lets the right code through and logs the session", async () => { + const headers = { "x-workshop-code": "secret", "x-workshop-session": "aaa" }; + assert.equal(await call("/agent/default/run", "POST", headers), 200); + assert.match(lines.at(-1)!, /run session=aaa runs=1 total=1/); + }); + + it("caps the runs of one session, then of all of them", async () => { + const run = (session: string) => call("/agent/default/run", "POST", { "x-workshop-code": "secret", "x-workshop-session": session }); + assert.equal(await run("aaa"), 200); // aaa: 2 of 2 + assert.equal(await run("aaa"), 429); + assert.equal(await run("bbb"), 200); // total: 3 of 3 + assert.equal(await run("ccc"), 429); + }); + + it("asks for nothing when there is no code", async () => { + const open = createServer(createGate((_req, res) => res.writeHead(200).end(), { maxRunsPerSession: 1, maxRunsTotal: 1 })); + await new Promise((resolve) => open.listen(0, resolve)); + const url = `http://localhost:${(open.address() as AddressInfo).port}/agent/default/run`; + assert.equal((await fetch(url, { method: "POST" })).status, 200); + assert.equal((await fetch(url, { method: "POST" })).status, 200); + open.close(); + }); +}); diff --git a/runtime/gate.ts b/runtime/gate.ts new file mode 100644 index 0000000..93669ff --- /dev/null +++ b/runtime/gate.ts @@ -0,0 +1,58 @@ +// The password gate and the spend limits of the shared deployment. +import { timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export type Handler = (req: IncomingMessage, res: ServerResponse) => unknown; + +export interface GateOptions { + /** The workshop password. Without one the gate lets everything through. */ + code?: string; + /** Runs one session (`X-Workshop-Session`) may start. */ + maxRunsPerSession: number; + /** Runs all sessions together may start, until the instance restarts. */ + maxRunsTotal: number; + /** One line per run, for the Cloud Run logs. */ + log?: (line: string) => void; +} + +function reject(res: ServerResponse, status: number, error: string) { + res.writeHead(status, { "content-type": "application/json", "access-control-allow-origin": "*" }); + res.end(JSON.stringify({ error })); +} + +function matches(given: string, wanted: string) { + const a = Buffer.from(given); + const b = Buffer.from(wanted); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** Wraps `next` so every request needs the password, and every run counts against the caps. */ +export function createGate(next: Handler, options: GateOptions): Handler { + const { code, maxRunsPerSession, maxRunsTotal, log = () => {} } = options; + if (!code) return next; + + const runsBySession = new Map(); + let runsTotal = 0; + + return (req, res) => { + const path = (req.url ?? "").split("?")[0]; + // `/info` and the CORS preflight stay open, so `tool/doctor.mjs` and the browser work without the code. + if (req.method === "OPTIONS" || path.endsWith("/info")) return next(req, res); + + if (!matches(String(req.headers["x-workshop-code"] ?? ""), code)) { + return reject(res, 401, "Missing or wrong workshop code."); + } + if (req.method === "POST" && path.endsWith("/run")) { + const session = String(req.headers["x-workshop-session"] ?? "").replace(/[^A-Za-z0-9-]/g, "").slice(0, 64) || "unknown"; + const runs = (runsBySession.get(session) ?? 0) + 1; + if (runs > maxRunsPerSession || runsTotal >= maxRunsTotal) { + log(`run refused session=${session} runs=${runs - 1} total=${runsTotal}`); + return reject(res, 429, "The workshop run limit is reached."); + } + runsBySession.set(session, runs); + runsTotal += 1; + log(`run session=${session} runs=${runs} total=${runsTotal}`); + } + return next(req, res); + }; +} diff --git a/runtime/package.json b/runtime/package.json index 4b3d315..52ab372 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -4,7 +4,9 @@ "description": "Copilot Runtime for the Calaverita Generator workshop (steps 01 and 02).", "type": "module", "scripts": { - "start": "tsx server.ts" + "start": "tsx server.ts", + "test": "tsx --test gate.test.ts", + "typecheck": "tsc --noEmit --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck --types node server.ts gate.test.ts" }, "dependencies": { "@copilotkit/runtime": "^1.79.0" diff --git a/runtime/server.ts b/runtime/server.ts index 208cae1..6f0faad 100644 --- a/runtime/server.ts +++ b/runtime/server.ts @@ -5,6 +5,7 @@ import { createServer } from "node:http"; import { networkInterfaces } from "node:os"; import { BuiltInAgent, CopilotRuntime } from "@copilotkit/runtime/v2"; import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node"; +import { createGate } from "./gate.js"; const basePath = "/api/copilotkit"; process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true"; @@ -57,7 +58,20 @@ function lanAddresses(): string[] { .map((net) => net!.address); } -createServer(createCopilotNodeListener({ runtime, basePath, cors: true })).listen( +const copilot = createCopilotNodeListener({ runtime, basePath, cors: true }); + +// The shared deployment sets WORKSHOP_CODE; a runtime on your machine leaves it +// out and asks for nothing. The app sends the code as X-Workshop-Code and a +// random id of its run as X-Workshop-Session, which the gate logs and counts +// (see gate.ts). +const guarded = createGate(copilot, { + code: process.env.WORKSHOP_CODE, + maxRunsPerSession: Number(process.env.MAX_RUNS_PER_SESSION ?? 100), + maxRunsTotal: Number(process.env.MAX_RUNS_TOTAL ?? 5000), + log: console.log, +}); + +createServer(guarded).listen( port, "0.0.0.0", () => { diff --git a/tool/doctor.mjs b/tool/doctor.mjs index 280994a..d318c6b 100644 --- a/tool/doctor.mjs +++ b/tool/doctor.mjs @@ -7,6 +7,7 @@ // node tool/doctor.mjs check everything // node tool/doctor.mjs --backend also check a hosted runtime, e.g. the shared one // node tool/doctor.mjs --only backend --backend check just that runtime +// --code (or WORKSHOP_CODE) the password, when the hosted backend asks for one import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -55,8 +56,17 @@ function readEnvFile(path) { return values; } -async function fetchWithTimeout(url, options = {}) { - return fetch(url, { ...options, signal: AbortSignal.timeout(8_000) }); +async function fetchWithTimeout(url, options = {}, timeout = 8_000) { + return fetch(url, { ...options, signal: AbortSignal.timeout(timeout) }); +} + +/** A hosted backend may be asleep (Cloud Run cold start): wait longer, and try twice. */ +async function fetchHosted(url, options = {}) { + try { + return await fetchWithTimeout(url, options, 30_000); + } catch { + return fetchWithTimeout(url, options, 30_000); + } } console.log(paint("1", `Calaverita Generator workshop: ${onlyBackend ? "backend" : "setup"} check\n`)); @@ -165,10 +175,10 @@ if (backendIndex !== -1) { report("fail", "--backend needs a URL", "Example: node tool/doctor.mjs --backend https://example.com/api/copilotkit"); } else { try { - const info = await fetchWithTimeout(`${backend}/info`); + const info = await fetchHosted(`${backend}/info`); if (!info.ok) throw new Error(`HTTP ${info.status}`); report("ok", `The backend answers at ${backend}`); - const preflight = await fetchWithTimeout(`${backend}/agent/default/run`, { + const preflight = await fetchHosted(`${backend}/agent/default/run`, { method: "OPTIONS", headers: { Origin: "http://localhost:5000", @@ -179,6 +189,19 @@ if (backendIndex !== -1) { const allowed = preflight.headers.get("access-control-allow-origin"); if (allowed) report("ok", `The backend allows browser requests (CORS: ${allowed})`); else report("fail", "The backend does not allow browser requests", "The Flutter web app calls it from the browser, so it must answer the CORS preflight."); + // An empty run is refused with 401 before it reaches the model, and with the right password it is a 400. + const codeIndex = process.argv.indexOf("--code"); + const code = (codeIndex !== -1 ? process.argv[codeIndex + 1] : process.env.WORKSHOP_CODE) ?? ""; + const open = await fetchHosted(`${backend}/agent/default/run`, { method: "POST" }); + if (open.status !== 401) { + report("ok", "The backend asks for no password"); + } else if (!code) { + report("ok", "The backend asks for a workshop password", "Pass it with --code to check it."); + } else { + const withCode = await fetchHosted(`${backend}/agent/default/run`, { method: "POST", headers: { "X-Workshop-Code": code } }); + if (withCode.status === 401) report("fail", "The backend refused the workshop password", "Check it with the organizer."); + else report("ok", "The workshop password is accepted"); + } if (backend.startsWith("http://") && !backend.includes("localhost")) { report("warn", "The backend is plain http", "A page served over https can't call it. Use https for a hosted app."); } diff --git a/tool/setup.mjs b/tool/setup.mjs index 8d4f570..65ceb6f 100644 --- a/tool/setup.mjs +++ b/tool/setup.mjs @@ -14,6 +14,7 @@ // --backend which backend to use (step 03 always runs its own server) // --url the runtime URL, for a custom backend // --agent the agent id, when the backend has more than one +// --code the workshop password of the shared backend (or set WORKSHOP_CODE) // --no-run set everything up, then print the commands instead of running them // --no-app start the backend and keep it running, without the app // --yes never ask, use the defaults and the options given @@ -132,8 +133,8 @@ function readEnvFile(path) { return values; } -async function fetchJson(url) { - const response = await fetch(url, { signal: AbortSignal.timeout(8_000) }); +async function fetchJson(url, timeout = 8_000) { + const response = await fetch(url, { signal: AbortSignal.timeout(timeout) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); } @@ -166,6 +167,8 @@ const stepDir = `${root}${step.dir}`; /** The backend the app talks to: its URL and agent id, and the folder to start if it is ours. */ let backend = null; +/** The password of the shared backend. The app sends it as a header; a runtime on your machine asks for none. */ +let workshopCode = ""; /** Makes sure `dir` has the model key in its .env, asking for it when it is missing. */ async function ensureKey(dir) { @@ -197,6 +200,33 @@ async function useOwnServer({ dir, port, agent }) { backend = { url: `http://localhost:${port}/api/copilotkit`, agent: option("agent") ?? agent, folder }; } +/** POSTs an empty run: 401 means the code is missing or wrong, a 400 means the backend let it through. */ +async function runStatus(base, agent, code) { + const response = await fetch(`${base.replace(/\/$/, "")}/agent/${agent}/run`, { + method: "POST", + headers: code ? { "X-Workshop-Code": code } : {}, + signal: AbortSignal.timeout(30_000), + }); + return response.status; +} + +/** The password of a backend: none when it asks for none, else the one given, checked before the app starts. */ +async function workshopCodeFor(base, agent) { + try { + if ((await runStatus(base, agent, "")) !== 401) return ""; + } catch { + return ""; // the doctor already said whether the backend answers + } + let code = option("code") ?? process.env.WORKSHOP_CODE; + for (let attempt = 0; attempt < 3; attempt++) { + code ??= (await askSecret("Workshop password (nothing is shown as you type):")) ?? ""; + if (code && (await runStatus(base, agent, code).catch(() => 0)) !== 401) return code; + say(`${paint("31", "✗")} The backend refused that password.`); + code = undefined; + } + return fail("The workshop password was not accepted.", "Ask the organizer for it and pass it with --code ."); +} + async function useExternal(url, defaultAgent) { const base = url.replace(/\/$/, ""); if (!runVisible("node", ["tool/doctor.mjs", "--only", "backend", "--backend", base])) { @@ -204,7 +234,7 @@ async function useExternal(url, defaultAgent) { } let agent = option("agent") ?? defaultAgent; try { - const ids = Object.keys((await fetchJson(`${base}/info`)).agents ?? {}); + const ids = Object.keys((await fetchJson(`${base}/info`, 30_000)).agents ?? {}); if (ids.length && !ids.includes(agent)) { agent = ids.length === 1 ? ids[0] : await choose("This backend has several agents. Which one?", ids.map((id) => ({ key: id, label: id })), ids[0]); say(`Using the agent "${agent}".`); @@ -231,6 +261,7 @@ if (stepKey !== "00" && step.server) { if (kind === "shared") { if (!config.sharedBackendUrl) fail("The shared backend is not configured yet.", "Use --backend local, or --backend custom --url ."); await useExternal(config.sharedBackendUrl, config.sharedAgentId ?? "default"); + workshopCode = await workshopCodeFor(config.sharedBackendUrl, config.sharedAgentId ?? "default"); } else if (kind === "custom") { say("\nTo make a backend with CopilotKit's tool, run this in another terminal and follow its steps:"); hint("npx copilotkit with-claude my-backend"); @@ -249,8 +280,10 @@ heading(`${stepKey === "00" ? "2" : "3"}. Getting the app ready`); if (!runVisible("fvm", ["flutter", "pub", "get"], stepDir)) fail("`flutter pub get` failed.", "If it can't fetch the CopilotKit SDK, check that you can reach github.com."); const appDir = `${stepDir}/apps/calaverita_app`; -const appArgs = ["flutter", "run", "-d", "chrome", ...(backend ? [`--dart-define=RUNTIME_URL=${backend.url}`, `--dart-define=AGENT_ID=${backend.agent}`] : [])]; +const appArgs = ["flutter", "run", "-d", "chrome", ...(backend ? [`--dart-define=RUNTIME_URL=${backend.url}`, `--dart-define=AGENT_ID=${backend.agent}`, ...(workshopCode ? [`--dart-define=WORKSHOP_CODE=${workshopCode}`] : [])] : [])]; const appCommand = `fvm ${appArgs.join(" ")}`; +/** The same command for the screen: the password is never printed. */ +const shownCommand = `fvm ${appArgs.map((arg) => (arg.startsWith("--dart-define=WORKSHOP_CODE=") ? "--dart-define=WORKSHOP_CODE=" : arg)).join(" ")}`; /** Starts the backend of ours in the background and waits until it answers. Returns a function that stops it. */ async function startBackend() { @@ -289,7 +322,7 @@ async function startBackend() { if (flag("no-run")) { heading("Ready. To run it yourself:"); if (backend?.folder) say(` 1. In one terminal: cd ${backend.folder.replace(root, "")} && npm start`); - say(` ${backend?.folder ? "2. In another:" : "Run:"} cd ${appDir.replace(root, "")} && ${appCommand}`); + say(` ${backend?.folder ? "2. In another:" : "Run:"} cd ${appDir.replace(root, "")} && ${shownCommand}`); process.exit(0); } diff --git a/tool/workshop.config.json b/tool/workshop.config.json index ada6888..442f58e 100644 --- a/tool/workshop.config.json +++ b/tool/workshop.config.json @@ -1,4 +1,4 @@ { - "sharedBackendUrl": null, + "sharedBackendUrl": "https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit", "sharedAgentId": "default" }