From fb45e86bc26fc34454115b179c783c0af7a02e54 Mon Sep 17 00:00:00 2001 From: pietroid Date: Fri, 23 Oct 2026 16:20:54 -0300 Subject: [PATCH 1/6] adding authentication and individual sessions --- .../apps/calaverita_app/lib/app/app.dart | 1 + .../calaverita_app/lib/app/app_config.dart | 29 +++++++++++++++++++ .../apps/calaverita_app/lib/app/app.dart | 1 + .../calaverita_app/lib/app/app_config.dart | 29 +++++++++++++++++++ .../apps/calaverita_app/lib/app/app.dart | 1 + .../calaverita_app/lib/app/app_config.dart | 29 +++++++++++++++++++ 03-custom-server/server/src/index.ts | 2 +- README.md | 20 ++++++++++++- runtime/.env.example | 4 +++ runtime/server.ts | 26 +++++++++++++++-- tool/setup.mjs | 10 +++++-- 11 files changed, 146 insertions(+), 6 deletions(-) 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..bac856b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ 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:** 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 +189,20 @@ The runtime URL is set in **one place per snapshot**: `AppConfig.defaultRuntimeUrl`, or run with `--dart-define=RUNTIME_URL=`. +The shared backend 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= +``` + +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. + ## How the code is organized Each step is a Dart workspace of small packages: @@ -225,6 +241,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/.env.example b/runtime/.env.example index 26df643..ae75ec6 100644 --- a/runtime/.env.example +++ b/runtime/.env.example @@ -1,3 +1,7 @@ # 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= diff --git a/runtime/server.ts b/runtime/server.ts index 208cae1..dec4b3b 100644 --- a/runtime/server.ts +++ b/runtime/server.ts @@ -1,7 +1,8 @@ // Copilot Runtime for the Calaverita Generator workshop, based on the // CopilotKit Flutter SDK's example runtime. Start it with `npm start`. import "./env.js"; -import { createServer } from "node:http"; +import { timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { networkInterfaces } from "node:os"; import { BuiltInAgent, CopilotRuntime } from "@copilotkit/runtime/v2"; import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node"; @@ -57,7 +58,28 @@ 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 this server ignores). `/info` +// and the CORS preflight stay open, so `tool/doctor.mjs` and the browser work +// without the code. +const workshopCode = process.env.WORKSHOP_CODE; + +function guarded(req: IncomingMessage, res: ServerResponse) { + const path = (req.url ?? "").split("?")[0]; + if (!workshopCode || req.method === "OPTIONS" || path.endsWith("/info")) return copilot(req, res); + + const given = Buffer.from(String(req.headers["x-workshop-code"] ?? "")); + const wanted = Buffer.from(workshopCode); + if (given.length === wanted.length && timingSafeEqual(given, wanted)) return copilot(req, res); + + res.writeHead(401, { "content-type": "application/json", "access-control-allow-origin": "*" }); + res.end(JSON.stringify({ error: "Missing or wrong workshop code." })); +} + +createServer(guarded).listen( port, "0.0.0.0", () => { diff --git a/tool/setup.mjs b/tool/setup.mjs index 8d4f570..bdd8015 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 @@ -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) { @@ -231,6 +234,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 = option("code") ?? process.env.WORKSHOP_CODE ?? (await askSecret("Workshop password (nothing is shown as you type, empty if there is none):")) ?? ""; } 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 +253,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 = workshopCode ? appCommand.replace(workshopCode, "") : appCommand; /** Starts the backend of ours in the background and waits until it answers. Returns a function that stops it. */ async function startBackend() { @@ -289,7 +295,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); } From 23427cfb71470d2044e4bf32e1c5b4e666f719df Mon Sep 17 00:00:00 2001 From: pietroid Date: Fri, 23 Oct 2026 17:01:40 -0300 Subject: [PATCH 2/6] feat: deploy the shared backend to Cloud Run with GitHub Actions - runtime/Dockerfile and ignore files: the image holds only server.ts and env.ts, and a source deploy uploads nothing else (no .env) - deploy_runtime workflow: builds the image, deploys to Cloud Run through Workload Identity and checks that the password gate is on - runtime_ci workflow: typechecks the runtime and builds the image on pull requests - runtime/deploy: one-time Google Cloud setup script and the steps - point tool/workshop.config.json at the deployed backend --- .github/workflows/deploy_runtime.yaml | 88 ++++++++++++++++++++++++++ .github/workflows/runtime_ci.yaml | 33 ++++++++++ runtime/.dockerignore | 6 ++ runtime/.gcloudignore | 8 +++ runtime/Dockerfile | 16 +++++ runtime/deploy/README.md | 87 +++++++++++++++++++++++++ runtime/deploy/setup_gcp.sh | 91 +++++++++++++++++++++++++++ runtime/package.json | 3 +- tool/workshop.config.json | 2 +- 9 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy_runtime.yaml create mode 100644 .github/workflows/runtime_ci.yaml create mode 100644 runtime/.dockerignore create mode 100644 runtime/.gcloudignore create mode 100644 runtime/Dockerfile create mode 100644 runtime/deploy/README.md create mode 100755 runtime/deploy/setup_gcp.sh diff --git a/.github/workflows/deploy_runtime.yaml b/.github/workflows/deploy_runtime.yaml new file mode 100644 index 0000000..92e6484 --- /dev/null +++ b/.github/workflows/deploy_runtime.yaml @@ -0,0 +1,88 @@ +# Deploys the shared backend (runtime/) to Cloud Run on every change to it on main. +# One-time setup of Google Cloud and of the GitHub secrets: runtime/deploy/README.md +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@v4 + + - name: Sign in to Google Cloud + id: auth + uses: google-github-actions/auth@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@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 are repository secrets. GitHub masks them in the + # logs; Cloud Run keeps them as environment variables of the revision. + - name: Deploy to Cloud Run + id: deploy + uses: google-github-actions/deploy-cloudrun@v2 + with: + project_id: ${{ env.PROJECT_ID }} + region: ${{ env.REGION }} + service: ${{ env.SERVICE }} + image: ${{ steps.image.outputs.name }} + env_vars: | + ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} + WORKSHOP_CODE=${{ secrets.WORKSHOP_CODE }} + flags: >- + --service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }} + --allow-unauthenticated + --max-instances=1 + --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' + 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..9536469 --- /dev/null +++ b/.github/workflows/runtime_ci.yaml @@ -0,0 +1,33 @@ +# 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@v4 + + - uses: actions/setup-node@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: Build the image + run: docker build --tag calaverita-runtime runtime 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/.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..e116d35 --- /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 ./ + +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/README.md b/runtime/deploy/README.md new file mode 100644 index 0000000..316cbf8 --- /dev/null +++ b/runtime/deploy/README.md @@ -0,0 +1,87 @@ +# Deploying the shared backend + +The shared backend is `runtime/` on Cloud Run, in the Google Cloud project +`gen-lang-client-0563181971`. GitHub Actions deploys it on every change to +`runtime/` on `main` (`.github/workflows/deploy_runtime.yaml`). Pull requests only +typecheck it and build the image (`.github/workflows/runtime_ci.yaml`). + +``` +push to main ──> GitHub Actions ──(Workload Identity, no key file)──> Google Cloud + │ build image ─> Artifact Registry ─> Cloud Run + │ ▲ ANTHROPIC_API_KEY, WORKSHOP_CODE + │ │ (GitHub repository secrets) + └ smoke test: /info is open, a run without the code is 401 +``` + +## Where each value lives + +| Value | Lives in | Why | +| --- | --- | --- | +| `ANTHROPIC_API_KEY` | GitHub secret | The workflow passes it to Cloud Run as an environment variable. | +| `WORKSHOP_CODE` | GitHub secret | Same. Use letters and digits only: the value is passed in a comma-separated list. | +| `GCP_WORKLOAD_IDENTITY_PROVIDER` | GitHub secret | Tells the job which identity provider to sign in with. | +| `GCP_SERVICE_ACCOUNT` | GitHub secret | The account the job acts as. | + +GitHub masks the secrets in the logs and they are never in the image. Cloud Run +shows environment variables in the revision's settings, though, so anyone with +view access to the Google Cloud project can read them there. Keep that access +small, or move the two values to Secret Manager if it ever grows. + +No Google key file exists anywhere. The provider only accepts tokens from this +repository on `main`. + +## Steps + +**1. Sign in** with an account that owns the project: + +```sh +gcloud auth login +``` + +**2. Run the setup once.** It enables the APIs, creates the registry, the two +service accounts and the Workload Identity provider, and it is safe to run +again. It creates no secret and asks for nothing: + +```sh +bash runtime/deploy/setup_gcp.sh +``` + +**3. Add the four GitHub secrets**, in the repository's *Settings → Secrets and +variables → Actions*, or with the GitHub CLI (`gh auth login` first). The script +prints the first two values. The last two commands ask for the value and hide it: + +```sh +gh secret set GCP_WORKLOAD_IDENTITY_PROVIDER --repo VeryGoodOpenSource/copilotkit_flutter_workshop +gh secret set GCP_SERVICE_ACCOUNT --repo VeryGoodOpenSource/copilotkit_flutter_workshop +gh secret set ANTHROPIC_API_KEY --repo VeryGoodOpenSource/copilotkit_flutter_workshop +gh secret set WORKSHOP_CODE --repo VeryGoodOpenSource/copilotkit_flutter_workshop +``` + +For a password, `openssl rand -hex 5` gives ten letters and digits. + +**4. Deploy.** Merge to `main`, or run it by hand: *Actions → deploy_runtime → +Run workflow* (`gh workflow run deploy_runtime.yaml`). The last step of the job +prints the URL and fails if the password gate is not on. + +**5. Point the workshop at it.** Set `sharedBackendUrl` in +`tool/workshop.config.json` to the URL ending in `/api/copilotkit`, and check it: + +```sh +node tool/doctor.mjs --only backend --backend +``` + +**6. Hand out the password** at the workshop. Participants give it to +`node tool/setup.mjs --code ` (or type it when asked). + +## Operating it + +- **Rotate the password or the key:** `gh secret set WORKSHOP_CODE` (or + `ANTHROPIC_API_KEY`), then run the workflow again so a new revision picks it up. +- **Turn it off after the workshop:** `gcloud run services delete calaverita-runtime --region us-central1`, + or revoke the Anthropic key. The deployment costs nothing while idle. +- **One instance on purpose** (`--max-instances=1`): the runtime keeps threads in + memory, and a second instance would not know them. For a large room, raise the + limit only after moving the threads out of memory. +- **Cold starts:** the first message after a quiet period takes a few seconds. Add + `--min-instances=1` to the `flags` in the workflow during the workshop to avoid it. +- **Logs:** `gcloud run services logs read calaverita-runtime --region us-central1`. diff --git a/runtime/deploy/setup_gcp.sh b/runtime/deploy/setup_gcp.sh new file mode 100755 index 0000000..4138113 --- /dev/null +++ b/runtime/deploy/setup_gcp.sh @@ -0,0 +1,91 @@ +#!/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 +# +# It creates no secret: the Anthropic key and the workshop password are GitHub +# repository secrets. See runtime/deploy/README.md for what each step is for. +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 \ + 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.admin, not run.developer: the first deploy sets the service public. +gc projects add-iam-policy-binding "$PROJECT" \ + --member "serviceAccount:$DEPLOYER_EMAIL" --role roles/run.admin --condition=None >/dev/null +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 "Done. Add these four secrets to GitHub (Settings > Secrets and variables > Actions):" +cat < Date: Fri, 23 Oct 2026 17:10:57 -0300 Subject: [PATCH 3/6] refactor: name the model key CLAUDE_API_KEY everywhere - the runtime, the step 03 server, setup and doctor read CLAUDE_API_KEY and hand it to the Anthropic SDK as ANTHROPIC_API_KEY - the deploy workflow passes the CLAUDE_API_KEY repository secret to Cloud Run - update the examples, the README and the deploy steps --- .github/workflows/deploy_runtime.yaml | 2 +- 03-custom-server/server/.env.example | 2 +- 03-custom-server/server/src/index.ts | 6 ++++-- README.md | 6 +++--- runtime/.env.example | 2 +- runtime/deploy/README.md | 8 ++++---- runtime/deploy/setup_gcp.sh | 8 ++++---- runtime/env.ts | 4 +++- tool/doctor.mjs | 2 +- tool/setup.mjs | 2 +- 10 files changed, 23 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy_runtime.yaml b/.github/workflows/deploy_runtime.yaml index 92e6484..800e551 100644 --- a/.github/workflows/deploy_runtime.yaml +++ b/.github/workflows/deploy_runtime.yaml @@ -68,7 +68,7 @@ jobs: service: ${{ env.SERVICE }} image: ${{ steps.image.outputs.name }} env_vars: | - ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_API_KEY=${{ secrets.CLAUDE_API_KEY }} WORKSHOP_CODE=${{ secrets.WORKSHOP_CODE }} flags: >- --service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }} diff --git a/03-custom-server/server/.env.example b/03-custom-server/server/.env.example index 1ba0f45..b151c03 100644 --- a/03-custom-server/server/.env.example +++ b/03-custom-server/server/.env.example @@ -1,5 +1,5 @@ # Required. Claude API key. Copy this file to .env and fill it in. Never commit .env. -ANTHROPIC_API_KEY= +CLAUDE_API_KEY= # Optional. Model id (default claude-sonnet-5-5). # CLAUDE_MODEL=claude-sonnet-5-5 # Optional. Server port (default 4100). diff --git a/03-custom-server/server/src/index.ts b/03-custom-server/server/src/index.ts index f3c073a..a6902b3 100644 --- a/03-custom-server/server/src/index.ts +++ b/03-custom-server/server/src/index.ts @@ -6,10 +6,12 @@ import { CopilotRuntime, createCopilotHonoHandler, InMemoryAgentRunner } from "@ import { CalaveritaAgent, MODEL } from "./agent.js"; process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true"; -if (!process.env.ANTHROPIC_API_KEY) { - console.error("ANTHROPIC_API_KEY is not set. Copy .env.example to .env and put your key in it."); +if (!process.env.CLAUDE_API_KEY) { + console.error("CLAUDE_API_KEY is not set. Copy .env.example to .env and put your key in it."); process.exit(1); } +// The Anthropic SDK reads ANTHROPIC_API_KEY, so hand it the key. +process.env.ANTHROPIC_API_KEY = process.env.CLAUDE_API_KEY; const runtime = new CopilotRuntime({ agents: { calaverita: new CalaveritaAgent() }, diff --git a/README.md b/README.md index bac856b..65bd7bd 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ npm install ``` Create your `runtime/.env` from the example, then open it and set -`ANTHROPIC_API_KEY=`: +`CLAUDE_API_KEY=`: ```sh cp .env.example .env # macOS and Linux @@ -169,7 +169,7 @@ Start the server in a first terminal. It uses port 4100, so the runtime of steps ```sh cd 03-custom-server/server -cp .env.example .env # set ANTHROPIC_API_KEY +cp .env.example .env # set CLAUDE_API_KEY npm install npm start # http://localhost:4100/api/copilotkit ``` @@ -200,7 +200,7 @@ fvm flutter run -d chrome \ 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 +`CLAUDE_API_KEY` on the host. The runtime then answers `401` without the password. `GET /info` and the CORS preflight stay open. ## How the code is organized diff --git a/runtime/.env.example b/runtime/.env.example index ae75ec6..c3b2911 100644 --- a/runtime/.env.example +++ b/runtime/.env.example @@ -1,6 +1,6 @@ # Copy this file to .env (git-ignored) and fill in your key. COPILOTKIT_MODEL=anthropic:claude-sonnet-5-5 -ANTHROPIC_API_KEY= +CLAUDE_API_KEY= # Only for a shared, hosted runtime: the password the participants send. Leave it # out on your machine. diff --git a/runtime/deploy/README.md b/runtime/deploy/README.md index 316cbf8..557530b 100644 --- a/runtime/deploy/README.md +++ b/runtime/deploy/README.md @@ -8,7 +8,7 @@ typecheck it and build the image (`.github/workflows/runtime_ci.yaml`). ``` push to main ──> GitHub Actions ──(Workload Identity, no key file)──> Google Cloud │ build image ─> Artifact Registry ─> Cloud Run - │ ▲ ANTHROPIC_API_KEY, WORKSHOP_CODE + │ ▲ CLAUDE_API_KEY, WORKSHOP_CODE │ │ (GitHub repository secrets) └ smoke test: /info is open, a run without the code is 401 ``` @@ -17,7 +17,7 @@ push to main ──> GitHub Actions ──(Workload Identity, no key file)── | Value | Lives in | Why | | --- | --- | --- | -| `ANTHROPIC_API_KEY` | GitHub secret | The workflow passes it to Cloud Run as an environment variable. | +| `CLAUDE_API_KEY` | GitHub secret | The workflow passes it to Cloud Run as the `CLAUDE_API_KEY` environment variable of the service. | | `WORKSHOP_CODE` | GitHub secret | Same. Use letters and digits only: the value is passed in a comma-separated list. | | `GCP_WORKLOAD_IDENTITY_PROVIDER` | GitHub secret | Tells the job which identity provider to sign in with. | | `GCP_SERVICE_ACCOUNT` | GitHub secret | The account the job acts as. | @@ -53,7 +53,7 @@ prints the first two values. The last two commands ask for the value and hide it ```sh gh secret set GCP_WORKLOAD_IDENTITY_PROVIDER --repo VeryGoodOpenSource/copilotkit_flutter_workshop gh secret set GCP_SERVICE_ACCOUNT --repo VeryGoodOpenSource/copilotkit_flutter_workshop -gh secret set ANTHROPIC_API_KEY --repo VeryGoodOpenSource/copilotkit_flutter_workshop +gh secret set CLAUDE_API_KEY --repo VeryGoodOpenSource/copilotkit_flutter_workshop gh secret set WORKSHOP_CODE --repo VeryGoodOpenSource/copilotkit_flutter_workshop ``` @@ -76,7 +76,7 @@ node tool/doctor.mjs --only backend --backend ## Operating it - **Rotate the password or the key:** `gh secret set WORKSHOP_CODE` (or - `ANTHROPIC_API_KEY`), then run the workflow again so a new revision picks it up. + `CLAUDE_API_KEY`), then run the workflow again so a new revision picks it up. - **Turn it off after the workshop:** `gcloud run services delete calaverita-runtime --region us-central1`, or revoke the Anthropic key. The deployment costs nothing while idle. - **One instance on purpose** (`--max-instances=1`): the runtime keeps threads in diff --git a/runtime/deploy/setup_gcp.sh b/runtime/deploy/setup_gcp.sh index 4138113..e901645 100755 --- a/runtime/deploy/setup_gcp.sh +++ b/runtime/deploy/setup_gcp.sh @@ -4,8 +4,8 @@ # gcloud auth login # bash runtime/deploy/setup_gcp.sh # -# It creates no secret: the Anthropic key and the workshop password are GitHub -# repository secrets. See runtime/deploy/README.md for what each step is for. +# It creates no secret: the Anthropic key (CLAUDE_API_KEY) and the workshop +# password are GitHub repository secrets. See runtime/deploy/README.md for what each step is for. set -euo pipefail PROJECT="${PROJECT:-gen-lang-client-0563181971}" @@ -74,14 +74,14 @@ cat < = { openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", + anthropic: "CLAUDE_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY", "google-gemini": "GOOGLE_API_KEY", @@ -40,3 +40,5 @@ if (keyVariable && !process.env[keyVariable]) { console.error("If runtime/.env does not exist yet, copy runtime/.env.example to runtime/.env first."); process.exit(1); } +// The Anthropic SDK underneath reads ANTHROPIC_API_KEY, so hand it the key. +if (provider === "anthropic") process.env.ANTHROPIC_API_KEY = process.env.CLAUDE_API_KEY; diff --git a/tool/doctor.mjs b/tool/doctor.mjs index 280994a..02e3b63 100644 --- a/tool/doctor.mjs +++ b/tool/doctor.mjs @@ -135,7 +135,7 @@ async function checkMachine() { } const env = readEnvFile(`${runtimeDir}/.env`); const model = process.env.COPILOTKIT_MODEL || env.COPILOTKIT_MODEL || "anthropic:claude-sonnet-5-5"; - const keyName = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; + const keyName = { openai: "OPENAI_API_KEY", anthropic: "CLAUDE_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; if (!keyName || process.env[keyName] || env[keyName]) { report("ok", keyName ? `${keyName} is set (the value is not shown)` : `Model ${model} needs no key`); } else { diff --git a/tool/setup.mjs b/tool/setup.mjs index bdd8015..cff98cb 100644 --- a/tool/setup.mjs +++ b/tool/setup.mjs @@ -175,7 +175,7 @@ async function ensureKey(dir) { const envPath = `${dir}/.env`; const env = readEnvFile(envPath); const model = process.env.COPILOTKIT_MODEL || env.COPILOTKIT_MODEL || "anthropic:claude-sonnet-5-5"; - const variable = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; + const variable = { openai: "OPENAI_API_KEY", anthropic: "CLAUDE_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; if (!variable) return; if (process.env[variable] || env[variable]) { say(`${paint("32", "✓")} ${variable} is set (the value is not shown).`); From 7dff95feda6ddfb7796bd806220d63d5cf2f979a Mon Sep 17 00:00:00 2001 From: pietroid Date: Fri, 23 Oct 2026 17:13:48 -0300 Subject: [PATCH 4/6] docs: name the shared backend URL in the README --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 65bd7bd..cd05dd0 100644 --- a/README.md +++ b/README.md @@ -35,9 +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 except the workshop password, which - you get at the workshop. It asks for it (hidden as you type), or pass - `--code `. +- **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 @@ -189,7 +190,8 @@ The runtime URL is set in **one place per snapshot**: `AppConfig.defaultRuntimeUrl`, or run with `--dart-define=RUNTIME_URL=`. -The shared backend also asks for a password. The app sends it, and a random id +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: From ee962a05dcc800def7bbb64395884f32dd9f88cd Mon Sep 17 00:00:00 2001 From: pietroid Date: Fri, 23 Oct 2026 17:15:03 -0300 Subject: [PATCH 5/6] docs: drop the deploy README, the setup script lists what it needs --- .github/workflows/deploy_runtime.yaml | 3 +- runtime/deploy/README.md | 87 --------------------------- runtime/deploy/setup_gcp.sh | 2 +- 3 files changed, 3 insertions(+), 89 deletions(-) delete mode 100644 runtime/deploy/README.md diff --git a/.github/workflows/deploy_runtime.yaml b/.github/workflows/deploy_runtime.yaml index 800e551..9ea70aa 100644 --- a/.github/workflows/deploy_runtime.yaml +++ b/.github/workflows/deploy_runtime.yaml @@ -1,5 +1,6 @@ # Deploys the shared backend (runtime/) to Cloud Run on every change to it on main. -# One-time setup of Google Cloud and of the GitHub secrets: runtime/deploy/README.md +# One-time setup of Google Cloud: runtime/deploy/setup_gcp.sh, which also lists the +# GitHub secrets this workflow needs. name: deploy_runtime on: diff --git a/runtime/deploy/README.md b/runtime/deploy/README.md deleted file mode 100644 index 557530b..0000000 --- a/runtime/deploy/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Deploying the shared backend - -The shared backend is `runtime/` on Cloud Run, in the Google Cloud project -`gen-lang-client-0563181971`. GitHub Actions deploys it on every change to -`runtime/` on `main` (`.github/workflows/deploy_runtime.yaml`). Pull requests only -typecheck it and build the image (`.github/workflows/runtime_ci.yaml`). - -``` -push to main ──> GitHub Actions ──(Workload Identity, no key file)──> Google Cloud - │ build image ─> Artifact Registry ─> Cloud Run - │ ▲ CLAUDE_API_KEY, WORKSHOP_CODE - │ │ (GitHub repository secrets) - └ smoke test: /info is open, a run without the code is 401 -``` - -## Where each value lives - -| Value | Lives in | Why | -| --- | --- | --- | -| `CLAUDE_API_KEY` | GitHub secret | The workflow passes it to Cloud Run as the `CLAUDE_API_KEY` environment variable of the service. | -| `WORKSHOP_CODE` | GitHub secret | Same. Use letters and digits only: the value is passed in a comma-separated list. | -| `GCP_WORKLOAD_IDENTITY_PROVIDER` | GitHub secret | Tells the job which identity provider to sign in with. | -| `GCP_SERVICE_ACCOUNT` | GitHub secret | The account the job acts as. | - -GitHub masks the secrets in the logs and they are never in the image. Cloud Run -shows environment variables in the revision's settings, though, so anyone with -view access to the Google Cloud project can read them there. Keep that access -small, or move the two values to Secret Manager if it ever grows. - -No Google key file exists anywhere. The provider only accepts tokens from this -repository on `main`. - -## Steps - -**1. Sign in** with an account that owns the project: - -```sh -gcloud auth login -``` - -**2. Run the setup once.** It enables the APIs, creates the registry, the two -service accounts and the Workload Identity provider, and it is safe to run -again. It creates no secret and asks for nothing: - -```sh -bash runtime/deploy/setup_gcp.sh -``` - -**3. Add the four GitHub secrets**, in the repository's *Settings → Secrets and -variables → Actions*, or with the GitHub CLI (`gh auth login` first). The script -prints the first two values. The last two commands ask for the value and hide it: - -```sh -gh secret set GCP_WORKLOAD_IDENTITY_PROVIDER --repo VeryGoodOpenSource/copilotkit_flutter_workshop -gh secret set GCP_SERVICE_ACCOUNT --repo VeryGoodOpenSource/copilotkit_flutter_workshop -gh secret set CLAUDE_API_KEY --repo VeryGoodOpenSource/copilotkit_flutter_workshop -gh secret set WORKSHOP_CODE --repo VeryGoodOpenSource/copilotkit_flutter_workshop -``` - -For a password, `openssl rand -hex 5` gives ten letters and digits. - -**4. Deploy.** Merge to `main`, or run it by hand: *Actions → deploy_runtime → -Run workflow* (`gh workflow run deploy_runtime.yaml`). The last step of the job -prints the URL and fails if the password gate is not on. - -**5. Point the workshop at it.** Set `sharedBackendUrl` in -`tool/workshop.config.json` to the URL ending in `/api/copilotkit`, and check it: - -```sh -node tool/doctor.mjs --only backend --backend -``` - -**6. Hand out the password** at the workshop. Participants give it to -`node tool/setup.mjs --code ` (or type it when asked). - -## Operating it - -- **Rotate the password or the key:** `gh secret set WORKSHOP_CODE` (or - `CLAUDE_API_KEY`), then run the workflow again so a new revision picks it up. -- **Turn it off after the workshop:** `gcloud run services delete calaverita-runtime --region us-central1`, - or revoke the Anthropic key. The deployment costs nothing while idle. -- **One instance on purpose** (`--max-instances=1`): the runtime keeps threads in - memory, and a second instance would not know them. For a large room, raise the - limit only after moving the threads out of memory. -- **Cold starts:** the first message after a quiet period takes a few seconds. Add - `--min-instances=1` to the `flags` in the workflow during the workshop to avoid it. -- **Logs:** `gcloud run services logs read calaverita-runtime --region us-central1`. diff --git a/runtime/deploy/setup_gcp.sh b/runtime/deploy/setup_gcp.sh index e901645..3b11d94 100755 --- a/runtime/deploy/setup_gcp.sh +++ b/runtime/deploy/setup_gcp.sh @@ -5,7 +5,7 @@ # bash runtime/deploy/setup_gcp.sh # # It creates no secret: the Anthropic key (CLAUDE_API_KEY) and the workshop -# password are GitHub repository secrets. See runtime/deploy/README.md for what each step is for. +# password are GitHub repository secrets, listed at the end. set -euo pipefail PROJECT="${PROJECT:-gen-lang-client-0563181971}" From 2408b42936cf0fc65cb78afb22d27d39f301bbc4 Mon Sep 17 00:00:00 2001 From: pietroid Date: Mon, 26 Oct 2026 15:16:58 -0300 Subject: [PATCH 6/6] fix: address review of the shared backend - key: name it ANTHROPIC_API_KEY everywhere again - gate: move the password gate to runtime/gate.ts, log and cap runs per session and in total, and test it with node:test in runtime_ci - setup, doctor: mask the password by argument, check it up front with an empty run, wait longer for a cold hosted backend - deploy: keep the key and password in Secret Manager, give the deployer run.developer and make the service public from setup_gcp.sh, use --cpu-boost, add a warm_runtime workflow - actions: pin to commit SHAs and add Dependabot - docs: one-line flutter run, note that a build made with the password contains it --- .github/dependabot.yml | 6 +++ .github/workflows/deploy_runtime.yaml | 23 +++++----- .github/workflows/runtime_ci.yaml | 8 +++- .github/workflows/warm_runtime.yaml | 31 +++++++++++++ 03-custom-server/server/.env.example | 2 +- 03-custom-server/server/src/index.ts | 6 +-- README.md | 23 ++++++++-- runtime/.env.example | 5 +- runtime/Dockerfile | 2 +- runtime/deploy/setup_gcp.sh | 55 +++++++++++++++++----- runtime/env.ts | 4 +- runtime/gate.test.ts | 66 +++++++++++++++++++++++++++ runtime/gate.ts | 58 +++++++++++++++++++++++ runtime/package.json | 3 +- runtime/server.ts | 30 +++++------- tool/doctor.mjs | 33 ++++++++++++-- tool/setup.mjs | 39 +++++++++++++--- 17 files changed, 324 insertions(+), 70 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/warm_runtime.yaml create mode 100644 runtime/gate.test.ts create mode 100644 runtime/gate.ts 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 index 9ea70aa..2e965c6 100644 --- a/.github/workflows/deploy_runtime.yaml +++ b/.github/workflows/deploy_runtime.yaml @@ -32,18 +32,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Sign in to Google Cloud id: auth - uses: google-github-actions/auth@v2 + 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@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGION }}-docker.pkg.dev username: oauth2accesstoken @@ -58,23 +58,23 @@ jobs: docker push "$IMAGE" echo "name=$IMAGE" >> "$GITHUB_OUTPUT" - # The key and the password are repository secrets. GitHub masks them in the - # logs; Cloud Run keeps them as environment variables of the revision. + # 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@v2 + 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 }} - env_vars: | - CLAUDE_API_KEY=${{ secrets.CLAUDE_API_KEY }} - WORKSHOP_CODE=${{ secrets.WORKSHOP_CODE }} + secrets: | + ANTHROPIC_API_KEY=anthropic-api-key:latest + WORKSHOP_CODE=workshop-code:latest flags: >- --service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }} - --allow-unauthenticated --max-instances=1 + --cpu-boost --memory=512Mi --timeout=900 @@ -83,7 +83,8 @@ jobs: 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' + 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 index 9536469..87512f3 100644 --- a/.github/workflows/runtime_ci.yaml +++ b/.github/workflows/runtime_ci.yaml @@ -15,9 +15,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -29,5 +29,9 @@ jobs: 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/03-custom-server/server/.env.example b/03-custom-server/server/.env.example index b151c03..1ba0f45 100644 --- a/03-custom-server/server/.env.example +++ b/03-custom-server/server/.env.example @@ -1,5 +1,5 @@ # Required. Claude API key. Copy this file to .env and fill it in. Never commit .env. -CLAUDE_API_KEY= +ANTHROPIC_API_KEY= # Optional. Model id (default claude-sonnet-5-5). # CLAUDE_MODEL=claude-sonnet-5-5 # Optional. Server port (default 4100). diff --git a/03-custom-server/server/src/index.ts b/03-custom-server/server/src/index.ts index a6902b3..f3c073a 100644 --- a/03-custom-server/server/src/index.ts +++ b/03-custom-server/server/src/index.ts @@ -6,12 +6,10 @@ import { CopilotRuntime, createCopilotHonoHandler, InMemoryAgentRunner } from "@ import { CalaveritaAgent, MODEL } from "./agent.js"; process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true"; -if (!process.env.CLAUDE_API_KEY) { - console.error("CLAUDE_API_KEY is not set. Copy .env.example to .env and put your key in it."); +if (!process.env.ANTHROPIC_API_KEY) { + console.error("ANTHROPIC_API_KEY is not set. Copy .env.example to .env and put your key in it."); process.exit(1); } -// The Anthropic SDK reads ANTHROPIC_API_KEY, so hand it the key. -process.env.ANTHROPIC_API_KEY = process.env.CLAUDE_API_KEY; const runtime = new CopilotRuntime({ agents: { calaverita: new CalaveritaAgent() }, diff --git a/README.md b/README.md index cd05dd0..ad60598 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ npm install ``` Create your `runtime/.env` from the example, then open it and set -`CLAUDE_API_KEY=`: +`ANTHROPIC_API_KEY=`: ```sh cp .env.example .env # macOS and Linux @@ -170,7 +170,7 @@ Start the server in a first terminal. It uses port 4100, so the runtime of steps ```sh cd 03-custom-server/server -cp .env.example .env # set CLAUDE_API_KEY +cp .env.example .env # set ANTHROPIC_API_KEY npm install npm start # http://localhost:4100/api/copilotkit ``` @@ -196,15 +196,28 @@ 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= +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 -`CLAUDE_API_KEY` on the host. The runtime then answers `401` without the +`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: diff --git a/runtime/.env.example b/runtime/.env.example index c3b2911..1384829 100644 --- a/runtime/.env.example +++ b/runtime/.env.example @@ -1,7 +1,10 @@ # Copy this file to .env (git-ignored) and fill in your key. COPILOTKIT_MODEL=anthropic:claude-sonnet-5-5 -CLAUDE_API_KEY= +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/Dockerfile b/runtime/Dockerfile index e116d35..c95eaa8 100644 --- a/runtime/Dockerfile +++ b/runtime/Dockerfile @@ -9,7 +9,7 @@ ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN npm ci --include=dev --no-audit --no-fund -COPY server.ts env.ts ./ +COPY server.ts env.ts gate.ts ./ USER node # Cloud Run sets PORT; server.ts reads it and listens on 0.0.0.0. diff --git a/runtime/deploy/setup_gcp.sh b/runtime/deploy/setup_gcp.sh index 3b11d94..7bfd281 100755 --- a/runtime/deploy/setup_gcp.sh +++ b/runtime/deploy/setup_gcp.sh @@ -4,8 +4,13 @@ # gcloud auth login # bash runtime/deploy/setup_gcp.sh # -# It creates no secret: the Anthropic key (CLAUDE_API_KEY) and the workshop -# password are GitHub repository secrets, listed at the end. +# 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}" @@ -23,7 +28,7 @@ step() { printf '\n\033[1m%s\033[0m\n' "$*"; } step "1. Enabling the APIs" gc services enable \ - run.googleapis.com artifactregistry.googleapis.com \ + run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com \ iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \ cloudresourcemanager.googleapis.com @@ -48,9 +53,12 @@ gc iam service-accounts describe "$DEPLOYER_EMAIL" >/dev/null 2>&1 \ 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.admin, not run.developer: the first deploy sets the service public. +# 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.admin --condition=None >/dev/null + --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 @@ -69,20 +77,45 @@ gc iam service-accounts add-iam-policy-binding "$DEPLOYER_EMAIL" \ PROVIDER_NAME="$(gc iam workload-identity-pools providers describe "$PROVIDER" --workload-identity-pool "$POOL" --location global --format='value(name)')" -step "Done. Add these four secrets to GitHub (Settings > Secrets and variables > Actions):" +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 < = { openai: "OPENAI_API_KEY", - anthropic: "CLAUDE_API_KEY", + anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY", "google-gemini": "GOOGLE_API_KEY", @@ -40,5 +40,3 @@ if (keyVariable && !process.env[keyVariable]) { console.error("If runtime/.env does not exist yet, copy runtime/.env.example to runtime/.env first."); process.exit(1); } -// The Anthropic SDK underneath reads ANTHROPIC_API_KEY, so hand it the key. -if (provider === "anthropic") process.env.ANTHROPIC_API_KEY = process.env.CLAUDE_API_KEY; diff --git a/runtime/gate.test.ts b/runtime/gate.test.ts new file mode 100644 index 0000000..cace50b --- /dev/null +++ b/runtime/gate.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, before, describe, it } from "node:test"; +import { createGate } from "./gate.js"; + +const lines: string[] = []; +let server: Server; +let base: string; + +const call = (path: string, method: string, headers: Record = {}) => + 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 f79685d..52ab372 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "start": "tsx server.ts", - "typecheck": "tsc --noEmit --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck --types node 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 dec4b3b..6f0faad 100644 --- a/runtime/server.ts +++ b/runtime/server.ts @@ -1,11 +1,11 @@ // Copilot Runtime for the Calaverita Generator workshop, based on the // CopilotKit Flutter SDK's example runtime. Start it with `npm start`. import "./env.js"; -import { timingSafeEqual } from "node:crypto"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +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"; @@ -61,23 +61,15 @@ function lanAddresses(): string[] { 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 this server ignores). `/info` -// and the CORS preflight stay open, so `tool/doctor.mjs` and the browser work -// without the code. -const workshopCode = process.env.WORKSHOP_CODE; - -function guarded(req: IncomingMessage, res: ServerResponse) { - const path = (req.url ?? "").split("?")[0]; - if (!workshopCode || req.method === "OPTIONS" || path.endsWith("/info")) return copilot(req, res); - - const given = Buffer.from(String(req.headers["x-workshop-code"] ?? "")); - const wanted = Buffer.from(workshopCode); - if (given.length === wanted.length && timingSafeEqual(given, wanted)) return copilot(req, res); - - res.writeHead(401, { "content-type": "application/json", "access-control-allow-origin": "*" }); - res.end(JSON.stringify({ error: "Missing or wrong workshop code." })); -} +// 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, diff --git a/tool/doctor.mjs b/tool/doctor.mjs index 02e3b63..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`)); @@ -135,7 +145,7 @@ async function checkMachine() { } const env = readEnvFile(`${runtimeDir}/.env`); const model = process.env.COPILOTKIT_MODEL || env.COPILOTKIT_MODEL || "anthropic:claude-sonnet-5-5"; - const keyName = { openai: "OPENAI_API_KEY", anthropic: "CLAUDE_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; + const keyName = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; if (!keyName || process.env[keyName] || env[keyName]) { report("ok", keyName ? `${keyName} is set (the value is not shown)` : `Model ${model} needs no key`); } else { @@ -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 cff98cb..65ceb6f 100644 --- a/tool/setup.mjs +++ b/tool/setup.mjs @@ -133,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(); } @@ -175,7 +175,7 @@ async function ensureKey(dir) { const envPath = `${dir}/.env`; const env = readEnvFile(envPath); const model = process.env.COPILOTKIT_MODEL || env.COPILOTKIT_MODEL || "anthropic:claude-sonnet-5-5"; - const variable = { openai: "OPENAI_API_KEY", anthropic: "CLAUDE_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; + const variable = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY" }[model.split(/[:/]/)[0].toLowerCase()]; if (!variable) return; if (process.env[variable] || env[variable]) { say(`${paint("32", "✓")} ${variable} is set (the value is not shown).`); @@ -200,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])) { @@ -207,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}".`); @@ -234,7 +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 = option("code") ?? process.env.WORKSHOP_CODE ?? (await askSecret("Workshop password (nothing is shown as you type, empty if there is none):")) ?? ""; + 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"); @@ -256,7 +283,7 @@ 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}`, ...(workshopCode ? [`--dart-define=WORKSHOP_CODE=${workshopCode}`] : [])] : [])]; const appCommand = `fvm ${appArgs.join(" ")}`; /** The same command for the screen: the password is never printed. */ -const shownCommand = workshopCode ? appCommand.replace(workshopCode, "") : appCommand; +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() {