diff --git a/.github/workflows/deploy_runtime.yaml b/.github/workflows/deploy_runtime.yaml new file mode 100644 index 0000000..9ea70aa --- /dev/null +++ b/.github/workflows/deploy_runtime.yaml @@ -0,0 +1,89 @@ +# 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@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: | + CLAUDE_API_KEY=${{ secrets.CLAUDE_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/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/.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 865afe8..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() }, @@ -17,7 +19,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..cd05dd0 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 @@ -90,7 +93,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 @@ -167,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 ANTHROPIC_API_KEY +cp .env.example .env # set CLAUDE_API_KEY npm install npm start # http://localhost:4100/api/copilotkit ``` @@ -187,6 +190,21 @@ 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= +``` + +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 +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 +243,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..c3b2911 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= +CLAUDE_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/.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/setup_gcp.sh b/runtime/deploy/setup_gcp.sh new file mode 100755 index 0000000..3b11d94 --- /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 (CLAUDE_API_KEY) and the workshop +# password are GitHub repository secrets, listed at the end. +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 < = { 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/runtime/package.json b/runtime/package.json index 4b3d315..f79685d 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -4,7 +4,8 @@ "description": "Copilot Runtime for the Calaverita Generator workshop (steps 01 and 02).", "type": "module", "scripts": { - "start": "tsx server.ts" + "start": "tsx server.ts", + "typecheck": "tsc --noEmit --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck --types node server.ts" }, "dependencies": { "@copilotkit/runtime": "^1.79.0" 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/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 8d4f570..cff98cb 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,13 +167,15 @@ 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) { 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).`); @@ -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); } 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" }