From eb7f34b422537bc1265586616421a3e5fc678327 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 1 Sep 2026 12:51:59 +0200 Subject: [PATCH 01/27] feat(backend): call any OpenAI-compatible endpoint from the AI adapt route --- apps/backend/.env.example | 9 +++-- apps/backend/package.json | 2 +- apps/backend/src/env.test.ts | 49 ++++++++++++++++++++++++++ apps/backend/src/env.ts | 12 ++++++- apps/backend/src/routes/visualize.ts | 10 +++--- pnpm-lock.yaml | 52 ++++++++++++++++++++++++++-- 6 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 apps/backend/src/env.test.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index d639840f1..4a575da66 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -15,8 +15,13 @@ WB_AUTH_PORT=allow-all # verification (local dev). When set, POST /api/workflows/:id/execute requires a # valid Turnstile token sent by the frontend as the cf-turnstile-token header. TURNSTILE_SECRET_KEY= -# OpenRouter key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt). +# API key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt). # Optional: leave empty to disable AI adapt (the endpoint returns 501). The # execution worker keeps its own key for running workflows. -OPENROUTER_API_KEY= +# OPENROUTER_API_KEY is the former name and is still read when this is empty. +AI_API_KEY= +# Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own +# network. Must be the base URL, without a trailing /chat/completions. +AI_BASE_URL=https://openrouter.ai/api/v1 +# Model id as the endpoint above understands it. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/apps/backend/package.json b/apps/backend/package.json index 85f2e79b7..f74e6fc13 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,8 +17,8 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@ai-sdk/openai-compatible": "^2.0.74", "@hono/node-server": "^1.14.0", - "@openrouter/ai-sdk-provider": "^2.8.0", "@temporalio/client": "catalog:", "@workflow-builder/execution-core": "workspace:*", "@workflow-builder/types": "workspace:*", diff --git a/apps/backend/src/env.test.ts b/apps/backend/src/env.test.ts new file mode 100644 index 000000000..493011ac9 --- /dev/null +++ b/apps/backend/src/env.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// env.ts reads process.env once at module load, so every case needs a fresh module. +async function loadEnv(values: Record) { + vi.resetModules(); + for (const [name, value] of Object.entries(values)) { + vi.stubEnv(name, value); + } + const module = await import('./env'); + return module.env; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('AI_API_KEY', () => { + it('takes AI_API_KEY when both names are set', async () => { + const env = await loadEnv({ AI_API_KEY: 'new-key', OPENROUTER_API_KEY: 'old-key' }); + + expect(env.AI_API_KEY).toBe('new-key'); + }); + + it('falls back to OPENROUTER_API_KEY so existing deployments keep working', async () => { + const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' }); + + expect(env.AI_API_KEY).toBe('old-key'); + }); + + it('reads an empty value as unset on both names', async () => { + const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: '' }); + + expect(env.AI_API_KEY).toBeNull(); + }); +}); + +describe('AI_BASE_URL', () => { + it('defaults to OpenRouter', async () => { + const env = await loadEnv({}); + + expect(env.AI_BASE_URL).toBe('https://openrouter.ai/api/v1'); + }); + + it('points at any OpenAI-compatible endpoint', async () => { + const env = await loadEnv({ AI_BASE_URL: 'http://vllm.internal:8000/v1' }); + + expect(env.AI_BASE_URL).toBe('http://vllm.internal:8000/v1'); + }); +}); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 278e18aa8..962ed25f7 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -2,6 +2,13 @@ function envOr(name: string, defaultValue: string): string { return process.env[name] ?? defaultValue; } +// Empty string counts as unset. Compose passes absent optionals through as +// `${VAR:-}`, so a bare `?? null` would read '' as a configured value and, for +// the key below, shadow the fallback. +function envOptional(name: string): string | null { + return process.env[name] || null; +} + // 127.0.0.1 (not `localhost`) matches the loopback-only docker bindings in // apps/backend/docker-compose.yml — see local-dev-binding.decision-log.md // for that decision. On some Windows / Node configs `localhost` resolves to @@ -18,7 +25,10 @@ export const env = { TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true', // Null = Turnstile verification disabled (local dev runs unprotected). TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null, + // Any OpenAI-compatible endpoint, including one inside your own network. + AI_BASE_URL: envOr('AI_BASE_URL', 'https://openrouter.ai/api/v1'), // Null = the "AI adapt" endpoint is disabled (returns 501). The worker keeps its own key. - OPENROUTER_API_KEY: process.env['OPENROUTER_API_KEY'] ?? null, + // OPENROUTER_API_KEY is the former name, still honoured so existing deployments keep working. + AI_API_KEY: envOptional('AI_API_KEY') ?? envOptional('OPENROUTER_API_KEY'), AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), }; diff --git a/apps/backend/src/routes/visualize.ts b/apps/backend/src/routes/visualize.ts index 8ebc5066c..11d8586a7 100644 --- a/apps/backend/src/routes/visualize.ts +++ b/apps/backend/src/routes/visualize.ts @@ -1,4 +1,4 @@ -import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { generateText } from 'ai'; import { Hono } from 'hono'; import { z } from 'zod'; @@ -50,7 +50,7 @@ export function createVisualizeRoutes( return blocked; } - if (!env.OPENROUTER_API_KEY) { + if (!env.AI_API_KEY) { return c.json({ code: 'adapt_disabled', message: 'AI adapt is not configured on this server.' }, 501); } @@ -61,11 +61,9 @@ export function createVisualizeRoutes( const { content, format } = parsed.data; try { - const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); - // Unlike the worker's AI agent activity, this route has no outer retry - // policy, so the SDK's default retries stay on. + const provider = createOpenAICompatible({ name: 'ai', baseURL: env.AI_BASE_URL, apiKey: env.AI_API_KEY }); const result = await generateText({ - model: openrouter.chat(env.AI_MODEL), + model: provider.chatModel(env.AI_MODEL), system: FORMAT_PROMPTS[format], // Low temperature for stable structured output. temperature: 0.2, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adecd8493..5bfc0f74b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,12 +204,12 @@ importers: apps/backend: dependencies: + '@ai-sdk/openai-compatible': + specifier: ^2.0.74 + version: 2.0.74(zod@4.3.6) '@hono/node-server': specifier: ^1.14.0 version: 1.19.14(hono@4.12.14) - '@openrouter/ai-sdk-provider': - specifier: ^2.8.0 - version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6) '@temporalio/client': specifier: 'catalog:' version: 1.23.0 @@ -773,12 +773,28 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.74': + resolution: {integrity: sha512-HdYUgacC08HjHyzL8Y59bjeOJTcsZWpHYZS0K8T4ChV/zYGktqbktzT0nJuhn3r9lVoMzA9Miw7abGQcdhI2yw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.23': resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.50': + resolution: {integrity: sha512-YAcB+7M1JhAYsHorTrWyldCyZihjCKr/QRXH2vFrara/+lwqNE7q5KzoucKLZ7ktFiUonhnhFhRoiymsq/2K2Q==} + engines: {node: '>=18.17'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.15': + resolution: {integrity: sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} @@ -4840,6 +4856,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + expect-type@1.1.0: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} @@ -7648,6 +7668,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + undici@7.24.4: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} @@ -8310,6 +8334,12 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.3.6 + '@ai-sdk/openai-compatible@2.0.74(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.15 + '@ai-sdk/provider-utils': 4.0.50(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -8317,6 +8347,18 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.50(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.15 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.1 + undici: 6.28.0 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.15': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 @@ -12953,6 +12995,8 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource-parser@3.1.1: {} + expect-type@1.1.0: {} expr-eval-fork@2.0.2: {} @@ -16511,6 +16555,8 @@ snapshots: undici-types@6.20.0: {} + undici@6.28.0: {} + undici@7.24.4: {} unified@11.0.5: From 7a50877dc28a42f93b6621e7d610da012dad4fb3 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 1 Sep 2026 13:53:58 +0200 Subject: [PATCH 02/27] feat(execution-worker): boot without an LLM key; AI nodes fail at call time --- README.md | 22 ++++---- apps/backend/README.md | 4 +- .../quick-start/standalone-app.mdx | 22 ++++---- apps/execution-worker/.env.example | 10 +++- apps/execution-worker/README.md | 26 ++++++--- apps/execution-worker/package.json | 2 +- .../src/engines/temporal/worker.ts | 20 ++++--- apps/execution-worker/src/env.test.ts | 51 +++++++++++++++++ apps/execution-worker/src/env.ts | 23 ++++---- .../src/executors/ai-agent.test.ts | 56 +++++++++++++++++++ .../src/executors/ai-agent.ts | 39 +++++++++++++ pnpm-lock.yaml | 18 +----- 12 files changed, 226 insertions(+), 67 deletions(-) create mode 100644 apps/execution-worker/src/env.test.ts create mode 100644 apps/execution-worker/src/executors/ai-agent.test.ts create mode 100644 apps/execution-worker/src/executors/ai-agent.ts diff --git a/README.md b/README.md index 4bec5f419..6c349757b 100644 --- a/README.md +++ b/README.md @@ -203,24 +203,24 @@ To stop: `Ctrl+C`, then `pnpm infra:down`. #### Connect a real LLM (optional) -AI Studio works with stub responses out of the box. To use a real model, add to both `apps/backend/.env` and `apps/execution-worker/.env`: +The stack starts without an LLM key: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. To make AI nodes work, add to both `apps/backend/.env` and `apps/execution-worker/.env`: ```env -OPENROUTER_API_KEY=sk-or-v1-... -AI_MODEL=anthropic/claude-3.5-haiku +AI_API_KEY=sk-or-v1-... +AI_MODEL=mistralai/mistral-small-3.2-24b-instruct ``` -If the key is missing the worker fails to start with `OPENROUTER_API_KEY is required`. If the model id is wrong the first AI node fails at runtime and the error surfaces in the UI log panel. +That default targets [OpenRouter](https://openrouter.ai). Any OpenAI-compatible endpoint works — set `AI_BASE_URL` (default `https://openrouter.ai/api/v1`) to a gateway or to a model hosted inside your own network, and nothing leaves it. If the model id is wrong the first AI node fails at runtime and the error surfaces in the UI log panel. ### Troubleshooting -| Symptom | Cause | Fix | -| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port | -| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | -| Worker exits with `OPENROUTER_API_KEY is required` | Real LLM env var missing | Set it in `apps/execution-worker/.env`. Optional unless you want a real LLM call | -| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily | -| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun | +| Symptom | Cause | Fix | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port | +| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | +| AI Agent node fails with `ai_not_configured` | No LLM key — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY` in `apps/execution-worker/.env` | +| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily | +| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun | For the full command reference, see the table in [`CLAUDE.md`](./CLAUDE.md) or the documentation site. diff --git a/apps/backend/README.md b/apps/backend/README.md index fdd487c41..f54473844 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -56,7 +56,9 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 ``` -Worker additionally needs `OPENROUTER_API_KEY` and optionally `AI_MODEL`. See [`apps/execution-worker/README.md`](../execution-worker/README.md). +Both also read `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` — all optional, and each side degrades +on its own when the key is missing: the backend's AI adapt endpoint returns 501, and the worker +runs everything except AI Agent nodes. See [`apps/execution-worker/README.md`](../execution-worker/README.md). ## Scripts diff --git a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx index 97877fc9d..3ff045379 100644 --- a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx +++ b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx @@ -160,24 +160,24 @@ To stop: `Ctrl+C`, then `pnpm infra:down`. ### Connect a real LLM (optional) -AI Studio works with stub responses out of the box. To use a real model, add to both `apps/backend/.env` and `apps/execution-worker/.env`: +The stack starts without an LLM key: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. To make AI nodes work, add to both `apps/backend/.env` and `apps/execution-worker/.env`: ```env -OPENROUTER_API_KEY=sk-or-v1-... -AI_MODEL=anthropic/claude-3.5-haiku +AI_API_KEY=sk-or-v1-... +AI_MODEL=mistralai/mistral-small-3.2-24b-instruct ``` -If the key is missing, the worker fails to start with `OPENROUTER_API_KEY is required`. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. +That default targets [OpenRouter](https://openrouter.ai). Any OpenAI-compatible endpoint works — set `AI_BASE_URL` (default `https://openrouter.ai/api/v1`) to a gateway or to a model hosted inside your own network, and nothing leaves it. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. ## Troubleshooting -| Symptom | Cause | Fix | -| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. | -| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | -| Worker exits with `OPENROUTER_API_KEY is required` | Real LLM env var missing | Set it in `apps/execution-worker/.env`. Optional unless you want a real LLM call. | -| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. | -| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. | +| Symptom | Cause | Fix | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. | +| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | +| AI Agent node fails with `ai_not_configured` | No LLM key — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY` in `apps/execution-worker/.env`. | +| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. | +| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. | ## See also diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index 94e2ecd4c..2c2d971ac 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -1,8 +1,14 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 -# OpenRouter — any model -OPENROUTER_API_KEY=sk-or-... +# LLM for AI Agent nodes. Optional: leave empty and the worker still starts and +# runs every other node type — AI Agent nodes then fail with `ai_not_configured`. +# OPENROUTER_API_KEY is the former name and is still read when this is empty. +AI_API_KEY= +# Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own +# network. Must be the base URL, without a trailing /chat/completions. +AI_BASE_URL=https://openrouter.ai/api/v1 +# Model id as the endpoint above understands it. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct # Tavily web search (optional). Enables the AI Agent's "Web search" tool. Get a diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index a9aa5d107..2ccbbd3ac 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -26,21 +26,31 @@ Requires Postgres + Temporal running. Start them with `pnpm infra:up`. ## Environment -See `.env.example`. Required: +See `.env.example`. Every variable has a working default: -| Var | Purpose | Default | -| -------------------- | ---------------------------------- | ---------------------------------------------------- | -| `OPENROUTER_API_KEY` | AI agent activities (**required**) | — | -| `DATABASE_URL` | Execution events + status | `postgresql://wb:wb@127.0.0.1:5432/workflow_builder` | -| `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | -| `AI_MODEL` | OpenRouter model ID | `anthropic/claude-3.5-haiku` | +| Var | Purpose | Default | +| ------------------ | ------------------------------------- | ---------------------------------------------------- | +| `DATABASE_URL` | Execution events + status | `postgresql://wb:wb@127.0.0.1:5432/workflow_builder` | +| `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | +| `AI_API_KEY` | LLM for AI Agent nodes (optional) | — (AI Agent nodes fail) | +| `AI_BASE_URL` | Any OpenAI-compatible endpoint | `https://openrouter.ai/api/v1` | +| `AI_MODEL` | Model id, as the endpoint spells it | `mistralai/mistral-small-3.2-24b-instruct` | +| `TAVILY_API_KEY` | AI Agent's web-search tool (optional) | — (tool disabled) | + +`AI_API_KEY` is optional by design: the worker boots without it and runs every non-AI node, and +an AI Agent node that is reached fails with the `ai_not_configured` code rather than taking the +whole worker down. `OPENROUTER_API_KEY` is the former name of this variable and is still read +when `AI_API_KEY` is empty. + +Point `AI_BASE_URL` at any OpenAI-compatible server — a gateway, or a model hosted inside your +own network — and no request leaves that network. ## Structure ``` src/ ├── database.ts # Raw SQL for exec events + status updates (no Drizzle — avoids backend schema coupling) -├── env.ts # Centralized env validation — fail fast at module load +├── env.ts # Centralized env reading, with the defaults documented above └── engines/ └── temporal/ ├── worker.ts # Worker bootstrap: executors + store, handed to WorkflowBuilderPlugin diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index b0ee15a9b..1ffe0450b 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -14,7 +14,7 @@ "test:watch": "vitest" }, "dependencies": { - "@openrouter/ai-sdk-provider": "^2.5.0", + "@ai-sdk/openai-compatible": "^2.0.74", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@workflow-builder/execution-core": "workspace:*", diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index 60732cc47..f050817c7 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -3,22 +3,27 @@ import { WorkflowBuilderPlugin } from '@workflowbuilder/temporal'; import 'dotenv/config'; import { fileURLToPath } from 'node:url'; -import { executeAiAgent } from '../../activities/ai-agent'; import { database } from '../../database'; import type { AiStudioNode } from '../../domain/ai-studio-nodes'; import { env } from '../../env'; +import { createAiAgentExecutor } from '../../executors/ai-agent'; import { executeDecision } from '../../executors/decision'; import { executeTrigger } from '../../executors/trigger'; import { executeVisualize } from '../../executors/visualize'; import { logger } from '../../logger'; import { withPayloadSizeWarning } from '../../store-payload-warning'; -const { createOpenRouter } = await import('@openrouter/ai-sdk-provider'); +if (!env.AI_API_KEY) { + logger.warn('no LLM key configured — AI Agent nodes will fail; every other node type runs as usual'); +} -const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); -const model = openrouter.chat(env.AI_MODEL); - -const aiAgentLogger = logger.child({ component: 'ai-agent' }); +const executeAIAgent = createAiAgentExecutor({ + apiKey: env.AI_API_KEY, + baseURL: env.AI_BASE_URL, + modelId: env.AI_MODEL, + logger: logger.child({ component: 'ai-agent' }), + tavilyApiKey: env.TAVILY_API_KEY, +}); // The plugin contributes the three activities that execute a graph. What each node // type actually does stays here, and so does where events are persisted. @@ -26,8 +31,7 @@ const plugin = new WorkflowBuilderPlugin({ executors: { 'ai-studio/trigger': executeTrigger, 'ai-studio/decision': executeDecision, - 'ai-studio/ai-agent': (node, context) => - executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }), + 'ai-studio/ai-agent': executeAIAgent, 'ai-studio/visualize': executeVisualize, }, store: withPayloadSizeWarning(database, logger), diff --git a/apps/execution-worker/src/env.test.ts b/apps/execution-worker/src/env.test.ts new file mode 100644 index 000000000..2e212e510 --- /dev/null +++ b/apps/execution-worker/src/env.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// env.ts reads process.env once at module load, so every case needs a fresh module. +async function loadEnv(values: Record) { + vi.resetModules(); + for (const [name, value] of Object.entries(values)) { + vi.stubEnv(name, value); + } + const module = await import('./env'); + return module.env; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('AI_API_KEY', () => { + // The worker used to throw at module load without a key. Booting keyless is the + // point: a deployment that runs no AI nodes should not need an LLM account. + it('is null when unset, rather than refusing to load', async () => { + const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: '' }); + + expect(env.AI_API_KEY).toBeNull(); + }); + + it('takes AI_API_KEY when both names are set', async () => { + const env = await loadEnv({ AI_API_KEY: 'new-key', OPENROUTER_API_KEY: 'old-key' }); + + expect(env.AI_API_KEY).toBe('new-key'); + }); + + it('falls back to OPENROUTER_API_KEY so existing deployments keep working', async () => { + const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' }); + + expect(env.AI_API_KEY).toBe('old-key'); + }); +}); + +describe('AI_BASE_URL', () => { + it('defaults to OpenRouter', async () => { + const env = await loadEnv({}); + + expect(env.AI_BASE_URL).toBe('https://openrouter.ai/api/v1'); + }); + + it('points at any OpenAI-compatible endpoint', async () => { + const env = await loadEnv({ AI_BASE_URL: 'http://vllm.internal:8000/v1' }); + + expect(env.AI_BASE_URL).toBe('http://vllm.internal:8000/v1'); + }); +}); diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index 5bbd6a61a..913252572 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -1,22 +1,25 @@ -// Centralized env — fail fast at module load with a readable message. -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is required — see apps/execution-worker/.env.example`); - } - return value; -} - function envOr(name: string, defaultValue: string): string { return process.env[name] ?? defaultValue; } +// Empty string counts as unset. Compose passes absent optionals through as +// `${VAR:-}`, so a bare `?? null` would read '' as a configured value and, for +// the key below, shadow the fallback. +function envOptional(name: string): string | null { + return process.env[name] || null; +} + // Defaults use 127.0.0.1 (not `localhost`) to match the loopback-only docker // bindings; see apps/backend/src/env.ts for the full reason. export const env = { DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'), - OPENROUTER_API_KEY: requireEnv('OPENROUTER_API_KEY'), + // Any OpenAI-compatible endpoint, including one inside your own network. + AI_BASE_URL: envOr('AI_BASE_URL', 'https://openrouter.ai/api/v1'), + // Null = the worker still boots and runs every non-AI node; AI Agent nodes + // fail with `ai_not_configured` when reached. OPENROUTER_API_KEY is the + // former name, still honoured so existing deployments keep working. + AI_API_KEY: envOptional('AI_API_KEY') ?? envOptional('OPENROUTER_API_KEY'), // Cheap, fast default for the public demo; quality-per-cost over frontier capability. AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), // Optional. Enables the AI Agent's web-search tool; agents run without it when unset. diff --git a/apps/execution-worker/src/executors/ai-agent.test.ts b/apps/execution-worker/src/executors/ai-agent.test.ts new file mode 100644 index 000000000..652c015e6 --- /dev/null +++ b/apps/execution-worker/src/executors/ai-agent.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { type ExecutionContext, NodeExecutionError } from '@workflow-builder/execution-core'; + +import type { AiAgentNode } from '../domain/ai-studio-nodes'; +import { createAiAgentExecutor } from './ai-agent'; + +function context(): ExecutionContext { + return { + workflowId: 'wf', + executionId: 'exec', + triggerPayload: {}, + nodeOutputs: {}, + variables: {}, + global: {}, + }; +} + +const node: AiAgentNode = { + id: 'a1', + type: 'ai-studio/ai-agent', + config: { systemPrompt: 'Summarise the input.' }, +}; + +const baseOptions = { baseURL: 'https://openrouter.ai/api/v1', modelId: 'some/model' }; + +describe('createAiAgentExecutor without a key', () => { + const executor = createAiAgentExecutor({ ...baseOptions, apiKey: null }); + + it('fails the node instead of the worker boot', () => { + // The factory itself must not throw — that is what lets the worker start and + // keep serving Trigger/Decision/Visualize nodes. + expect(() => executor(node, context())).toThrow(NodeExecutionError); + }); + + it('reports a code the UI can key off, and names the variable to set', () => { + try { + executor(node, context()); + expect.unreachable('executor should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NodeExecutionError); + expect((error as NodeExecutionError).code).toBe('ai_not_configured'); + expect((error as NodeExecutionError).message).toContain('AI_API_KEY'); + } + }); +}); + +describe('createAiAgentExecutor with a key', () => { + it('builds the executor without calling the endpoint', () => { + // Construction is eager (the model is built once per worker), so it has to + // stay free of network I/O — the endpoint may not even be reachable at boot. + const executor = createAiAgentExecutor({ ...baseOptions, apiKey: 'test-key' }); + + expect(executor).toBeTypeOf('function'); + }); +}); diff --git a/apps/execution-worker/src/executors/ai-agent.ts b/apps/execution-worker/src/executors/ai-agent.ts new file mode 100644 index 000000000..5ccf6749b --- /dev/null +++ b/apps/execution-worker/src/executors/ai-agent.ts @@ -0,0 +1,39 @@ +// Builds the AI Agent executor, and decides what happens when no LLM key is set. +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; + +import { type LoggerPort, NodeExecutionError, type NodeExecutor } from '@workflow-builder/execution-core'; + +import { executeAiAgent } from '../activities/ai-agent'; +import type { AiAgentNode } from '../domain/ai-studio-nodes'; + +type AiAgentExecutorOptions = { + // Null when no key is configured. The worker still boots; only this node type + // is unavailable, so a graph of Trigger/Decision/Visualize nodes runs fine. + apiKey: string | null; + baseURL: string; + modelId: string; + logger?: LoggerPort; + tavilyApiKey?: string; +}; + +export function createAiAgentExecutor(options: AiAgentExecutorOptions): NodeExecutor { + const { apiKey, baseURL, modelId, logger, tavilyApiKey } = options; + + if (!apiKey) { + // Thrown when the node is reached rather than at boot, so a missing key + // costs one failed node instead of the whole worker. The node activity + // profile retries it once — harmless, since nothing here can succeed on a + // second attempt. + return () => { + throw new NodeExecutionError( + 'ai_not_configured', + 'AI is not configured on this worker — set AI_API_KEY (see apps/execution-worker/.env.example).', + ); + }; + } + + const provider = createOpenAICompatible({ name: 'ai', baseURL, apiKey }); + const model = provider.chatModel(modelId); + + return (node, context) => executeAiAgent(node, context, { model, logger, tavilyApiKey }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bfc0f74b..42fbe33d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -438,9 +438,9 @@ importers: apps/execution-worker: dependencies: - '@openrouter/ai-sdk-provider': - specifier: ^2.5.0 - version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6) + '@ai-sdk/openai-compatible': + specifier: ^2.0.74 + version: 2.0.74(zod@4.3.6) '@temporalio/worker': specifier: 'catalog:' version: 1.23.0(tslib@2.8.1) @@ -2322,13 +2322,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@openrouter/ai-sdk-provider@2.8.0': - resolution: {integrity: sha512-oDDW/0KMqz4suHVloB9sNv0YyKLGNYf1FTevXH6adDkid5dsmbbcYuiEsbIhpZSZtHa6o5AVjK1jEAfePOLxww==} - engines: {node: '>=18'} - peerDependencies: - ai: ^6.0.0 - zod: ^3.25.0 || ^4.0.0 - '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -10044,11 +10037,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.18.0 - '@openrouter/ai-sdk-provider@2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6)': - dependencies: - ai: 6.0.168(zod@4.3.6) - zod: 4.3.6 - '@opentelemetry/api@1.9.0': {} '@oslojs/encoding@1.1.0': {} From f11e3f4ac03938f146a7954a4697e82fffc130ab Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 1 Sep 2026 14:26:30 +0200 Subject: [PATCH 03/27] feat(backend): Temporal namespace and TLS/mTLS/API-key connection config --- CLAUDE.md | 2 +- apps/backend/.env.example | 21 ++++ apps/backend/README.md | 22 ++++ apps/backend/src/engine/index.ts | 17 ++- .../src/engine/temporal-connection.test.ts | 101 ++++++++++++++++++ .../backend/src/engine/temporal-connection.ts | 79 ++++++++++++++ apps/backend/src/env.ts | 10 ++ 7 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/engine/temporal-connection.test.ts create mode 100644 apps/backend/src/engine/temporal-connection.ts diff --git a/CLAUDE.md b/CLAUDE.md index ae6a3d247..f392f2d73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ UI: `packages/ui/` (imported as `@workflowbuilder/ui`; styles via `@workflowbuil - Temporal server on `7233` (gRPC) - Temporal UI on http://localhost:8233 -Backend reads `DATABASE_URL` and `TEMPORAL_ADDRESS`; defaults work out of the box. `pnpm infra:down` stops everything. +Backend reads `DATABASE_URL` and `TEMPORAL_ADDRESS`; defaults work out of the box. Pointing either app at a secured cluster or Temporal Cloud is env-only (`TEMPORAL_NAMESPACE`, `TEMPORAL_TLS`, `TEMPORAL_API_KEY`, `TEMPORAL_TLS_*_PATH`) - see `apps/backend/README.md` "Connecting to a secured Temporal cluster". `pnpm infra:down` stops everything. ## Code Quality diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 4a575da66..eaa2ebfb6 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,5 +1,26 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 +# Must match the worker's namespace. Leave as `default` for the bundled dev cluster. +TEMPORAL_NAMESPACE=default +# Connection security. All optional, and all default to a plaintext connection — +# which is what the bundled dev cluster expects. +# +# TEMPORAL_TLS: leave empty to infer (setting any credential below turns TLS on), +# `true` to require TLS with the OS trust store, `false` to assert plaintext. +TEMPORAL_TLS= +# API key auth, as used by Temporal Cloud. Implies TLS. +TEMPORAL_API_KEY= +# Paths to PEM files, read when the connection opens. CA for a private issuer; +# the cert/key pair for mTLS (set both or neither, and not alongside an API key). +TEMPORAL_TLS_CA_PATH= +TEMPORAL_TLS_CERT_PATH= +TEMPORAL_TLS_KEY_PATH= +# +# Temporal Cloud looks like this: +# TEMPORAL_ADDRESS=..tmprl.cloud:7233 +# TEMPORAL_NAMESPACE=. +# TEMPORAL_API_KEY= + PORT=3001 # Hostname to bind. Default 127.0.0.1 (loopback only - single-tenant local dev). # Change ONLY if you understand: this server has no auth, anyone reachable on diff --git a/apps/backend/README.md b/apps/backend/README.md index f54473844..9eb10671f 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -60,6 +60,28 @@ Both also read `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` — all optional, and on its own when the key is missing: the backend's AI adapt endpoint returns 501, and the worker runs everything except AI Agent nodes. See [`apps/execution-worker/README.md`](../execution-worker/README.md). +### Connecting to a secured Temporal cluster + +The defaults above open a plaintext connection to the bundled dev cluster. Everything about the +connection is env-driven, so a hardened cluster or Temporal Cloud needs no code change: + +| Var | Purpose | Default | +| ------------------------ | ------------------------------------------------------------ | --------- | +| `TEMPORAL_NAMESPACE` | Namespace to use. Must match the worker's | `default` | +| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | — | +| `TEMPORAL_API_KEY` | API key auth (Temporal Cloud). Implies TLS | — | +| `TEMPORAL_TLS_CA_PATH` | PEM for a private certificate authority | — | +| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set with the key | — | +| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set with the certificate | — | + +Any credential turns TLS on by itself, so `TEMPORAL_TLS` only has to be set to force TLS with no +credentials, or to assert plaintext. Contradictory combinations — half an mTLS pair, an API key +together with a client certificate, or credentials alongside `TEMPORAL_TLS=false` — are rejected +with an explanatory error when the connection opens, rather than being silently ignored. + +For Temporal Cloud, set `TEMPORAL_ADDRESS` to `..tmprl.cloud:7233`, +`TEMPORAL_NAMESPACE` to `.`, and `TEMPORAL_API_KEY` to your key. + ## Scripts All scripts run from the monorepo root. Grouped by purpose: diff --git a/apps/backend/src/engine/index.ts b/apps/backend/src/engine/index.ts index 179170e7d..228bf05ff 100644 --- a/apps/backend/src/engine/index.ts +++ b/apps/backend/src/engine/index.ts @@ -5,6 +5,7 @@ import type { WorkflowEnginePort } from '@workflow-builder/execution-core/workfl import type { BaseNode } from '@workflow-builder/types/workflow-execution/execution-model'; import { env } from '../env'; +import { buildTemporalConnectionOptions } from './temporal-connection'; let engine: WorkflowEnginePort | undefined; @@ -13,7 +14,21 @@ export function getWorkflowEngine(): WorkflowEnginePort { engine = new TemporalWorkflowEngine({ // A factory rather than a ready client: the connection is opened on the first // submit, so booting the backend does not require Temporal to be reachable. - client: async () => new Client({ connection: await Connection.connect({ address: env.TEMPORAL_ADDRESS }) }), + // Misconfigured TEMPORAL_* values therefore surface on that first submit + // rather than at boot. + client: async () => { + const connection = await Connection.connect({ + address: env.TEMPORAL_ADDRESS, + ...buildTemporalConnectionOptions({ + tls: env.TEMPORAL_TLS, + apiKey: env.TEMPORAL_API_KEY, + caPath: env.TEMPORAL_TLS_CA_PATH, + certPath: env.TEMPORAL_TLS_CERT_PATH, + keyPath: env.TEMPORAL_TLS_KEY_PATH, + }), + }); + return new Client({ connection, namespace: env.TEMPORAL_NAMESPACE }); + }, }); } return engine; diff --git a/apps/backend/src/engine/temporal-connection.test.ts b/apps/backend/src/engine/temporal-connection.test.ts new file mode 100644 index 000000000..9cf309a36 --- /dev/null +++ b/apps/backend/src/engine/temporal-connection.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; + +const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; + +function config(overrides: Partial): TemporalConnectionConfig { + return { ...empty, ...overrides }; +} + +// Keyed by path so a test can tell the CA apart from the client cert. +function fakeReader() { + return vi.fn((path: string) => new TextEncoder().encode(`contents-of:${path}`)); +} + +function bytes(path: string) { + return new TextEncoder().encode(`contents-of:${path}`); +} + +describe('buildTemporalConnectionOptions', () => { + it('stays plaintext when nothing is configured — the local-dev default', () => { + expect(buildTemporalConnectionOptions(empty, fakeReader())).toEqual({}); + }); + + it('enables TLS with the OS trust store on TEMPORAL_TLS=true', () => { + expect(buildTemporalConnectionOptions(config({ tls: 'true' }), fakeReader())).toEqual({ tls: true }); + }); + + it('stays plaintext on an explicit TEMPORAL_TLS=false', () => { + expect(buildTemporalConnectionOptions(config({ tls: 'false' }), fakeReader())).toEqual({}); + }); + + // Mirrors the SDK's own normalizeTlsConfig, which turns TLS on whenever an + // apiKey is present. Temporal Cloud rejects an API key sent in the clear. + it('infers TLS from an API key alone', () => { + expect(buildTemporalConnectionOptions(config({ apiKey: 'tmprl-key' }), fakeReader())).toEqual({ + tls: true, + apiKey: 'tmprl-key', + }); + }); + + it('loads a private CA certificate', () => { + const read = fakeReader(); + + expect(buildTemporalConnectionOptions(config({ caPath: '/certs/ca.pem' }), read)).toEqual({ + tls: { serverRootCACertificate: bytes('/certs/ca.pem') }, + }); + expect(read).toHaveBeenCalledWith('/certs/ca.pem'); + }); + + it('loads a full mTLS pair alongside the CA', () => { + const options = buildTemporalConnectionOptions( + config({ caPath: '/certs/ca.pem', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }), + fakeReader(), + ); + + expect(options).toEqual({ + tls: { + serverRootCACertificate: bytes('/certs/ca.pem'), + clientCertPair: { crt: bytes('/certs/client.pem'), key: bytes('/certs/client.key') }, + }, + }); + }); +}); + +describe('buildTemporalConnectionOptions rejects contradictory config at connect time', () => { + it('refuses half an mTLS pair', () => { + expect(() => buildTemporalConnectionOptions(config({ certPath: '/certs/client.pem' }), fakeReader())).toThrow( + /must be set together/, + ); + expect(() => buildTemporalConnectionOptions(config({ keyPath: '/certs/client.key' }), fakeReader())).toThrow( + /must be set together/, + ); + }); + + it('refuses an API key and a client certificate together', () => { + const both = config({ apiKey: 'k', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }); + + expect(() => buildTemporalConnectionOptions(both, fakeReader())).toThrow(/not both/); + }); + + it('refuses credentials that TEMPORAL_TLS=false would silently discard', () => { + const contradiction = config({ tls: 'false', apiKey: 'k' }); + + expect(() => buildTemporalConnectionOptions(contradiction, fakeReader())).toThrow(/contradicts/); + }); + + it('refuses a TEMPORAL_TLS value that is neither true nor false', () => { + expect(() => buildTemporalConnectionOptions(config({ tls: 'yes' }), fakeReader())).toThrow(/must be 'true'/); + }); + + it('names the variable and the path when a certificate cannot be read', () => { + const explode = vi.fn(() => { + throw new Error('ENOENT'); + }); + + expect(() => buildTemporalConnectionOptions(config({ caPath: '/nope.pem' }), explode)).toThrow( + /TEMPORAL_TLS_CA_PATH \(\/nope\.pem\)/, + ); + }); +}); diff --git a/apps/backend/src/engine/temporal-connection.ts b/apps/backend/src/engine/temporal-connection.ts new file mode 100644 index 000000000..342855f7e --- /dev/null +++ b/apps/backend/src/engine/temporal-connection.ts @@ -0,0 +1,79 @@ +// Turns the TEMPORAL_* env vars into Temporal connection options. +// +// Duplicated by design in apps/execution-worker/src/engines/temporal/temporal-connection.ts: +// the two SDKs type their options separately (the client accepts a function for +// `apiKey`, the worker's native connection only a string), so a shared module would +// have to pick one and cast. Keep the two copies in sync. +import type { ConnectionOptions } from '@temporalio/client'; +import { readFileSync } from 'node:fs'; + +export type TemporalConnectionConfig = { + // Raw TEMPORAL_TLS. Tri-state on purpose: unset means "infer from the rest", + // which is not the same as an explicit 'false'. + tls: string | null; + apiKey: string | null; + caPath: string | null; + certPath: string | null; + keyPath: string | null; +}; + +type TemporalConnectionOptions = Pick; + +export function buildTemporalConnectionOptions( + config: TemporalConnectionConfig, + readFile: (path: string) => Uint8Array = readFileSync, +): TemporalConnectionOptions { + const { tls, apiKey, caPath, certPath, keyPath } = config; + + if (tls !== null && tls !== 'true' && tls !== 'false') { + throw new Error(`TEMPORAL_TLS must be 'true' or 'false' (got '${tls}').`); + } + if (Boolean(certPath) !== Boolean(keyPath)) { + throw new Error( + 'TEMPORAL_TLS_CERT_PATH and TEMPORAL_TLS_KEY_PATH must be set together — mTLS needs both halves of the pair.', + ); + } + if (apiKey && certPath) { + throw new Error('Set either TEMPORAL_API_KEY or an mTLS client certificate pair, not both.'); + } + + const hasTlsMaterial = Boolean(apiKey || caPath || certPath); + if (tls === 'false' && hasTlsMaterial) { + throw new Error( + 'TEMPORAL_TLS=false contradicts the TEMPORAL_API_KEY / TEMPORAL_TLS_*_PATH values that are set — remove one side.', + ); + } + + // Material implies TLS, matching what the SDK already does for apiKey. Being + // explicit here keeps the client and the worker in step and makes it testable. + if (tls !== 'true' && !hasTlsMaterial) { + // Plaintext — the local-dev default, and what this backend did before. + return {}; + } + + const certificates = { + ...(caPath ? { serverRootCACertificate: read(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), + ...(certPath && keyPath + ? { + clientCertPair: { + crt: read(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), + key: read(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), + }, + } + : {}), + }; + + return { + // `true` means TLS with the OS trust store — enough for Temporal Cloud. + tls: Object.keys(certificates).length > 0 ? certificates : true, + ...(apiKey ? { apiKey } : {}), + }; +} + +function read(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { + try { + return readFile(path); + } catch (error) { + throw new Error(`Could not read ${variable} (${path}).`, { cause: error }); + } +} diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 962ed25f7..c39e507f9 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -19,6 +19,16 @@ export const env = { HOST: envOr('HOST', '127.0.0.1'), DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'), + // Must match the worker's. Temporal Cloud spells it `.`. + TEMPORAL_NAMESPACE: envOr('TEMPORAL_NAMESPACE', 'default'), + // Unset means "infer": any of the credentials below turns TLS on. Set it to + // 'true' to require TLS on its own, or 'false' to assert plaintext. + TEMPORAL_TLS: envOptional('TEMPORAL_TLS'), + TEMPORAL_API_KEY: envOptional('TEMPORAL_API_KEY'), + // Paths, read at connect time. CA for a private issuer; the cert/key pair for mTLS. + TEMPORAL_TLS_CA_PATH: envOptional('TEMPORAL_TLS_CA_PATH'), + TEMPORAL_TLS_CERT_PATH: envOptional('TEMPORAL_TLS_CERT_PATH'), + TEMPORAL_TLS_KEY_PATH: envOptional('TEMPORAL_TLS_KEY_PATH'), // 0 disables (dev default); the deploy compose sets both RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')), RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')), From 552a823d97095ae003f90eea4a35313722113d2d Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 1 Sep 2026 14:34:25 +0200 Subject: [PATCH 04/27] feat(execution-worker): Temporal namespace and TLS/mTLS/API-key connection config --- apps/execution-worker/.env.example | 20 ++++ apps/execution-worker/README.md | 23 ++-- .../temporal/temporal-connection.test.ts | 104 ++++++++++++++++++ .../engines/temporal/temporal-connection.ts | 79 +++++++++++++ .../src/engines/temporal/worker.ts | 18 ++- apps/execution-worker/src/env.ts | 11 ++ 6 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 apps/execution-worker/src/engines/temporal/temporal-connection.test.ts create mode 100644 apps/execution-worker/src/engines/temporal/temporal-connection.ts diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index 2c2d971ac..2011d2e98 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -1,5 +1,25 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 +# Must match the backend's namespace. Leave as `default` for the bundled dev cluster. +TEMPORAL_NAMESPACE=default +# Connection security. All optional, and all default to a plaintext connection — +# which is what the bundled dev cluster expects. +# +# TEMPORAL_TLS: leave empty to infer (setting any credential below turns TLS on), +# `true` to require TLS with the OS trust store, `false` to assert plaintext. +TEMPORAL_TLS= +# API key auth, as used by Temporal Cloud. Implies TLS. +TEMPORAL_API_KEY= +# Paths to PEM files, read when the connection opens. CA for a private issuer; +# the cert/key pair for mTLS (set both or neither, and not alongside an API key). +TEMPORAL_TLS_CA_PATH= +TEMPORAL_TLS_CERT_PATH= +TEMPORAL_TLS_KEY_PATH= +# +# Temporal Cloud looks like this: +# TEMPORAL_ADDRESS=..tmprl.cloud:7233 +# TEMPORAL_NAMESPACE=. +# TEMPORAL_API_KEY= # LLM for AI Agent nodes. Optional: leave empty and the worker still starts and # runs every other node type — AI Agent nodes then fail with `ai_not_configured`. diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index 2ccbbd3ac..7fea1f698 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -28,14 +28,15 @@ Requires Postgres + Temporal running. Start them with `pnpm infra:up`. See `.env.example`. Every variable has a working default: -| Var | Purpose | Default | -| ------------------ | ------------------------------------- | ---------------------------------------------------- | -| `DATABASE_URL` | Execution events + status | `postgresql://wb:wb@127.0.0.1:5432/workflow_builder` | -| `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | -| `AI_API_KEY` | LLM for AI Agent nodes (optional) | — (AI Agent nodes fail) | -| `AI_BASE_URL` | Any OpenAI-compatible endpoint | `https://openrouter.ai/api/v1` | -| `AI_MODEL` | Model id, as the endpoint spells it | `mistralai/mistral-small-3.2-24b-instruct` | -| `TAVILY_API_KEY` | AI Agent's web-search tool (optional) | — (tool disabled) | +| Var | Purpose | Default | +| -------------------- | ------------------------------------- | ---------------------------------------------------- | +| `DATABASE_URL` | Execution events + status | `postgresql://wb:wb@127.0.0.1:5432/workflow_builder` | +| `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | +| `TEMPORAL_NAMESPACE` | Namespace. Must match the backend's | `default` | +| `AI_API_KEY` | LLM for AI Agent nodes (optional) | — (AI Agent nodes fail) | +| `AI_BASE_URL` | Any OpenAI-compatible endpoint | `https://openrouter.ai/api/v1` | +| `AI_MODEL` | Model id, as the endpoint spells it | `mistralai/mistral-small-3.2-24b-instruct` | +| `TAVILY_API_KEY` | AI Agent's web-search tool (optional) | — (tool disabled) | `AI_API_KEY` is optional by design: the worker boots without it and runs every non-AI node, and an AI Agent node that is reached fails with the `ai_not_configured` code rather than taking the @@ -45,6 +46,11 @@ when `AI_API_KEY` is empty. Point `AI_BASE_URL` at any OpenAI-compatible server — a gateway, or a model hosted inside your own network — and no request leaves that network. +The connection to Temporal is env-driven too: `TEMPORAL_TLS`, `TEMPORAL_API_KEY` and the +`TEMPORAL_TLS_CA_PATH` / `TEMPORAL_TLS_CERT_PATH` / `TEMPORAL_TLS_KEY_PATH` trio cover a hardened +cluster or Temporal Cloud. The backend reads the same variables and must agree on the namespace — +the full table is in [`apps/backend/README.md`](../backend/README.md#connecting-to-a-secured-temporal-cluster). + ## Structure ``` @@ -64,6 +70,7 @@ own: one executor per node type and the database as the store port. ## Temporal specifics - **Task queue:** `workflow-execution`, read from `plugin.taskQueue` so the backend and the worker cannot drift apart. Both default to the same constant in the package. +- **Namespace:** `TEMPORAL_NAMESPACE`, default `default`. Unlike the task queue this is _not_ shared through the plugin, so the two apps have to be configured to agree — a mismatch is silent, the worker simply never sees the backend's submissions. - **Workflow ID:** `execution-` — deterministic, lets the backend cancel by execution ID. Also owned by the package. - **Activity timeouts:** DB activities get 30s / 5 retries; node activities (may call LLMs) get 10m / 2 retries. Exported as `DEFAULT_DATABASE_ACTIVITY_PROFILE` and `DEFAULT_NODE_ACTIVITY_PROFILE`. - **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior — the reference executors have not been classified yet. diff --git a/apps/execution-worker/src/engines/temporal/temporal-connection.test.ts b/apps/execution-worker/src/engines/temporal/temporal-connection.test.ts new file mode 100644 index 000000000..195c635fa --- /dev/null +++ b/apps/execution-worker/src/engines/temporal/temporal-connection.test.ts @@ -0,0 +1,104 @@ +// Mirrors apps/backend/src/engine/temporal-connection.test.ts. The builder is +// duplicated per app (see the note in temporal-connection.ts), so the tests are too — +// that is what catches the copies drifting apart. +import { describe, expect, it, vi } from 'vitest'; + +import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; + +const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; + +function config(overrides: Partial): TemporalConnectionConfig { + return { ...empty, ...overrides }; +} + +// Keyed by path so a test can tell the CA apart from the client cert. +function fakeReader() { + return vi.fn((path: string) => new TextEncoder().encode(`contents-of:${path}`)); +} + +function bytes(path: string) { + return new TextEncoder().encode(`contents-of:${path}`); +} + +describe('buildTemporalConnectionOptions', () => { + it('stays plaintext when nothing is configured — the local-dev default', () => { + expect(buildTemporalConnectionOptions(empty, fakeReader())).toEqual({}); + }); + + it('enables TLS with the OS trust store on TEMPORAL_TLS=true', () => { + expect(buildTemporalConnectionOptions(config({ tls: 'true' }), fakeReader())).toEqual({ tls: true }); + }); + + it('stays plaintext on an explicit TEMPORAL_TLS=false', () => { + expect(buildTemporalConnectionOptions(config({ tls: 'false' }), fakeReader())).toEqual({}); + }); + + // Mirrors the SDK's own normalizeTlsConfig, which turns TLS on whenever an + // apiKey is present. Temporal Cloud rejects an API key sent in the clear. + it('infers TLS from an API key alone', () => { + expect(buildTemporalConnectionOptions(config({ apiKey: 'tmprl-key' }), fakeReader())).toEqual({ + tls: true, + apiKey: 'tmprl-key', + }); + }); + + it('loads a private CA certificate', () => { + const read = fakeReader(); + + expect(buildTemporalConnectionOptions(config({ caPath: '/certs/ca.pem' }), read)).toEqual({ + tls: { serverRootCACertificate: bytes('/certs/ca.pem') }, + }); + expect(read).toHaveBeenCalledWith('/certs/ca.pem'); + }); + + it('loads a full mTLS pair alongside the CA', () => { + const options = buildTemporalConnectionOptions( + config({ caPath: '/certs/ca.pem', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }), + fakeReader(), + ); + + expect(options).toEqual({ + tls: { + serverRootCACertificate: bytes('/certs/ca.pem'), + clientCertPair: { crt: bytes('/certs/client.pem'), key: bytes('/certs/client.key') }, + }, + }); + }); +}); + +describe('buildTemporalConnectionOptions rejects contradictory config at connect time', () => { + it('refuses half an mTLS pair', () => { + expect(() => buildTemporalConnectionOptions(config({ certPath: '/certs/client.pem' }), fakeReader())).toThrow( + /must be set together/, + ); + expect(() => buildTemporalConnectionOptions(config({ keyPath: '/certs/client.key' }), fakeReader())).toThrow( + /must be set together/, + ); + }); + + it('refuses an API key and a client certificate together', () => { + const both = config({ apiKey: 'k', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }); + + expect(() => buildTemporalConnectionOptions(both, fakeReader())).toThrow(/not both/); + }); + + it('refuses credentials that TEMPORAL_TLS=false would silently discard', () => { + const contradiction = config({ tls: 'false', apiKey: 'k' }); + + expect(() => buildTemporalConnectionOptions(contradiction, fakeReader())).toThrow(/contradicts/); + }); + + it('refuses a TEMPORAL_TLS value that is neither true nor false', () => { + expect(() => buildTemporalConnectionOptions(config({ tls: 'yes' }), fakeReader())).toThrow(/must be 'true'/); + }); + + it('names the variable and the path when a certificate cannot be read', () => { + const explode = vi.fn(() => { + throw new Error('ENOENT'); + }); + + expect(() => buildTemporalConnectionOptions(config({ caPath: '/nope.pem' }), explode)).toThrow( + /TEMPORAL_TLS_CA_PATH \(\/nope\.pem\)/, + ); + }); +}); diff --git a/apps/execution-worker/src/engines/temporal/temporal-connection.ts b/apps/execution-worker/src/engines/temporal/temporal-connection.ts new file mode 100644 index 000000000..94496d2b2 --- /dev/null +++ b/apps/execution-worker/src/engines/temporal/temporal-connection.ts @@ -0,0 +1,79 @@ +// Turns the TEMPORAL_* env vars into Temporal connection options. +// +// Duplicated by design from apps/backend/src/engine/temporal-connection.ts: the two +// SDKs type their options separately (the client accepts a function for `apiKey`, the +// worker's native connection only a string), so a shared module would have to pick one +// and cast. Keep the two copies in sync. +import type { NativeConnectionOptions } from '@temporalio/worker'; +import { readFileSync } from 'node:fs'; + +export type TemporalConnectionConfig = { + // Raw TEMPORAL_TLS. Tri-state on purpose: unset means "infer from the rest", + // which is not the same as an explicit 'false'. + tls: string | null; + apiKey: string | null; + caPath: string | null; + certPath: string | null; + keyPath: string | null; +}; + +type TemporalConnectionOptions = Pick; + +export function buildTemporalConnectionOptions( + config: TemporalConnectionConfig, + readFile: (path: string) => Uint8Array = readFileSync, +): TemporalConnectionOptions { + const { tls, apiKey, caPath, certPath, keyPath } = config; + + if (tls !== null && tls !== 'true' && tls !== 'false') { + throw new Error(`TEMPORAL_TLS must be 'true' or 'false' (got '${tls}').`); + } + if (Boolean(certPath) !== Boolean(keyPath)) { + throw new Error( + 'TEMPORAL_TLS_CERT_PATH and TEMPORAL_TLS_KEY_PATH must be set together — mTLS needs both halves of the pair.', + ); + } + if (apiKey && certPath) { + throw new Error('Set either TEMPORAL_API_KEY or an mTLS client certificate pair, not both.'); + } + + const hasTlsMaterial = Boolean(apiKey || caPath || certPath); + if (tls === 'false' && hasTlsMaterial) { + throw new Error( + 'TEMPORAL_TLS=false contradicts the TEMPORAL_API_KEY / TEMPORAL_TLS_*_PATH values that are set — remove one side.', + ); + } + + // Material implies TLS, matching what the SDK already does for apiKey. Being + // explicit here keeps the client and the worker in step and makes it testable. + if (tls !== 'true' && !hasTlsMaterial) { + // Plaintext — the local-dev default, and what this worker did before. + return {}; + } + + const certificates = { + ...(caPath ? { serverRootCACertificate: read(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), + ...(certPath && keyPath + ? { + clientCertPair: { + crt: read(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), + key: read(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), + }, + } + : {}), + }; + + return { + // `true` means TLS with the OS trust store — enough for Temporal Cloud. + tls: Object.keys(certificates).length > 0 ? certificates : true, + ...(apiKey ? { apiKey } : {}), + }; +} + +function read(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { + try { + return readFile(path); + } catch (error) { + throw new Error(`Could not read ${variable} (${path}).`, { cause: error }); + } +} diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index f050817c7..f03d17933 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -12,6 +12,7 @@ import { executeTrigger } from '../../executors/trigger'; import { executeVisualize } from '../../executors/visualize'; import { logger } from '../../logger'; import { withPayloadSizeWarning } from '../../store-payload-warning'; +import { buildTemporalConnectionOptions } from './temporal-connection'; if (!env.AI_API_KEY) { logger.warn('no LLM key configured — AI Agent nodes will fail; every other node type runs as usual'); @@ -37,15 +38,26 @@ const plugin = new WorkflowBuilderPlugin({ store: withPayloadSizeWarning(database, logger), }); -// without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS -const connection = await NativeConnection.connect({ address: env.TEMPORAL_ADDRESS }); +// without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS. +// Contradictory TEMPORAL_* values throw here, before the worker starts polling. +const connection = await NativeConnection.connect({ + address: env.TEMPORAL_ADDRESS, + ...buildTemporalConnectionOptions({ + tls: env.TEMPORAL_TLS, + apiKey: env.TEMPORAL_API_KEY, + caPath: env.TEMPORAL_TLS_CA_PATH, + certPath: env.TEMPORAL_TLS_CERT_PATH, + keyPath: env.TEMPORAL_TLS_KEY_PATH, + }), +}); const worker = await Worker.create({ connection, + namespace: env.TEMPORAL_NAMESPACE, taskQueue: plugin.taskQueue, workflowsPath: fileURLToPath(new URL('workflows.ts', import.meta.url)), plugins: [plugin], }); -logger.info('execution worker started', { taskQueue: plugin.taskQueue }); +logger.info('execution worker started', { taskQueue: plugin.taskQueue, namespace: env.TEMPORAL_NAMESPACE }); await worker.run(); diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index 913252572..beff3da6d 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -14,6 +14,17 @@ function envOptional(name: string): string | null { export const env = { DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'), + // Must match the backend's, or the worker polls a queue nobody submits to. + // Temporal Cloud spells it `.`. + TEMPORAL_NAMESPACE: envOr('TEMPORAL_NAMESPACE', 'default'), + // Unset means "infer": any of the credentials below turns TLS on. Set it to + // 'true' to require TLS on its own, or 'false' to assert plaintext. + TEMPORAL_TLS: envOptional('TEMPORAL_TLS'), + TEMPORAL_API_KEY: envOptional('TEMPORAL_API_KEY'), + // Paths, read at connect time. CA for a private issuer; the cert/key pair for mTLS. + TEMPORAL_TLS_CA_PATH: envOptional('TEMPORAL_TLS_CA_PATH'), + TEMPORAL_TLS_CERT_PATH: envOptional('TEMPORAL_TLS_CERT_PATH'), + TEMPORAL_TLS_KEY_PATH: envOptional('TEMPORAL_TLS_KEY_PATH'), // Any OpenAI-compatible endpoint, including one inside your own network. AI_BASE_URL: envOr('AI_BASE_URL', 'https://openrouter.ai/api/v1'), // Null = the worker still boots and runs every non-AI node; AI Agent nodes From d824d39c1fa771af0aa9d66ce807037fcf9daf21 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 1 Sep 2026 15:18:56 +0200 Subject: [PATCH 05/27] build(deps): put the AI SDK packages in the catalog --- apps/backend/package.json | 4 +- apps/execution-worker/package.json | 4 +- pnpm-lock.yaml | 118 ++++++++++++----------------- pnpm-workspace.yaml | 5 ++ 4 files changed, 56 insertions(+), 75 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index f74e6fc13..339d9be78 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,13 +17,13 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { - "@ai-sdk/openai-compatible": "^2.0.74", + "@ai-sdk/openai-compatible": "catalog:", "@hono/node-server": "^1.14.0", "@temporalio/client": "catalog:", "@workflow-builder/execution-core": "workspace:*", "@workflow-builder/types": "workspace:*", "@workflowbuilder/temporal": "workspace:*", - "ai": "^6.0.168", + "ai": "catalog:", "dotenv": "^17.4.2", "drizzle-orm": "^0.44.0", "hono": "^4.7.0", diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index 1ffe0450b..8e383fdb7 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -14,13 +14,13 @@ "test:watch": "vitest" }, "dependencies": { - "@ai-sdk/openai-compatible": "^2.0.74", + "@ai-sdk/openai-compatible": "catalog:", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@workflow-builder/execution-core": "workspace:*", "@workflow-builder/types": "workspace:*", "@workflowbuilder/temporal": "workspace:*", - "ai": "^6.0.0", + "ai": "catalog:", "dotenv": "^17.4.2", "postgres": "^3.4.5", "tsx": "^4.19.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42fbe33d5..49afd9654 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@ai-sdk/openai-compatible': + specifier: ^2.0.74 + version: 2.0.74 '@base-ui/react': specifier: 1.7.0 version: 1.7.0 @@ -42,6 +45,9 @@ catalogs: '@xyflow/react': specifier: 12.10.0 version: 12.10.0 + ai: + specifier: ^6.0.168 + version: 6.0.168 ajv: specifier: ^8.18.0 version: 8.18.0 @@ -205,7 +211,7 @@ importers: apps/backend: dependencies: '@ai-sdk/openai-compatible': - specifier: ^2.0.74 + specifier: 'catalog:' version: 2.0.74(zod@4.3.6) '@hono/node-server': specifier: ^1.14.0 @@ -223,7 +229,7 @@ importers: specifier: workspace:* version: link:../../packages/temporal ai: - specifier: ^6.0.168 + specifier: 'catalog:' version: 6.0.168(zod@4.3.6) dotenv: specifier: ^17.4.2 @@ -439,7 +445,7 @@ importers: apps/execution-worker: dependencies: '@ai-sdk/openai-compatible': - specifier: ^2.0.74 + specifier: 'catalog:' version: 2.0.74(zod@4.3.6) '@temporalio/worker': specifier: 'catalog:' @@ -457,7 +463,7 @@ importers: specifier: workspace:* version: link:../../packages/temporal ai: - specifier: ^6.0.0 + specifier: 'catalog:' version: 6.0.168(zod@4.3.6) dotenv: specifier: ^17.4.2 @@ -560,7 +566,7 @@ importers: version: 4.1.0 i18next: specifier: ^24.0.0 - version: 24.2.3(typescript@5.6.3) + version: 24.2.3(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.0.0 version: 8.0.5 @@ -581,7 +587,7 @@ importers: version: 19.1.0(react@19.1.0) react-i18next: specifier: ^15.0.0 - version: 15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-mentions-ts: specifier: ^5.4.7 version: 5.4.7(class-variance-authority@0.7.1)(clsx@2.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwind-merge@3.5.0) @@ -621,10 +627,10 @@ importers: version: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) @@ -4845,10 +4851,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -8337,7 +8339,7 @@ snapshots: dependencies: '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.1 zod: 4.3.6 '@ai-sdk/provider-utils@4.0.50(zod@4.3.6)': @@ -10378,17 +10380,6 @@ snapshots: '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.26.7) '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.26.7) - '@svgr/core@8.1.0(typescript@5.6.3)': - dependencies: - '@babel/core': 7.26.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) - camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@5.6.3) - snake-case: 3.0.4 - transitivePeerDependencies: - - supports-color - - typescript - '@svgr/core@8.1.0(typescript@5.9.3)': dependencies: '@babel/core': 7.26.7 @@ -10405,16 +10396,6 @@ snapshots: '@babel/types': 7.29.0 entities: 4.5.0 - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.6.3))': - dependencies: - '@babel/core': 7.26.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) - '@svgr/core': 8.1.0(typescript@5.6.3) - '@svgr/hast-util-to-babel-ast': 8.0.0 - svg-parser: 2.0.4 - transitivePeerDependencies: - - supports-color - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': dependencies: '@babel/core': 7.26.7 @@ -11183,6 +11164,19 @@ snapshots: optionalDependencies: typescript: 5.6.3 + '@vue/language-core@2.2.0(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + '@vue/shared@3.5.33': {} '@webassemblyjs/ast@1.14.1': @@ -12046,15 +12040,6 @@ snapshots: jiti: 2.6.1 typescript: 5.6.3 - cosmiconfig@8.3.6(typescript@5.6.3): - dependencies: - import-fresh: 3.3.0 - js-yaml: 4.1.0 - parse-json: 5.2.0 - path-type: 4.0.0 - optionalDependencies: - typescript: 5.6.3 - cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.0 @@ -12981,8 +12966,6 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.6: {} - eventsource-parser@3.1.1: {} expect-type@1.1.0: {} @@ -13623,12 +13606,6 @@ snapshots: dependencies: '@babel/runtime': 7.29.7 - i18next@24.2.3(typescript@5.6.3): - dependencies: - '@babel/runtime': 7.27.0 - optionalDependencies: - typescript: 5.6.3 - i18next@24.2.3(typescript@5.9.3): dependencies: '@babel/runtime': 7.27.0 @@ -15383,15 +15360,6 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-i18next@15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - '@babel/runtime': 7.27.0 - html-parse-stringify: 3.0.1 - i18next: 24.2.3(typescript@5.6.3) - react: 19.1.0 - optionalDependencies: - react-dom: 19.1.0(react@19.1.0) - react-i18next@15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.0 @@ -16767,6 +16735,25 @@ snapshots: - rollup - supports-color + vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): + dependencies: + '@microsoft/api-extractor': 7.58.7(@types/node@22.12.0) + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.9.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + typescript: 5.9.3 + optionalDependencies: + vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-lib-inject-css@2.2.2(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@ast-grep/napi': 0.36.3 @@ -16783,17 +16770,6 @@ snapshots: picocolors: 1.1.1 vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) - vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@svgr/core': 8.1.0(typescript@5.6.3) - '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.6.3)) - vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) - transitivePeerDependencies: - - rollup - - supports-color - - typescript - vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.57.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd77a2389..fd2f49212 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,11 @@ catalog: '@temporalio/testing': ^1.23.0 '@temporalio/worker': ^1.23.0 '@temporalio/workflow': ^1.23.0 + # AI SDK. The provider major is tied to the `ai` major — v2 speaks to `ai` v6, + # v3 to `ai` v7 — so the two only move together, and the backend and the worker + # have to agree or they build the same model against different request shapes. + 'ai': ^6.0.168 + '@ai-sdk/openai-compatible': ^2.0.74 useNodeVersion: 22.12.0 engineStrict: true From dc01fa5b3119c0ad90688464600279b2e164d72f Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 1 Sep 2026 15:29:48 +0200 Subject: [PATCH 06/27] feat(deploy): pass LLM endpoint and Temporal connection config through the demo stack --- deploy/ai-studio/.env.example | 43 +++++++++++++++++++++++++---- deploy/ai-studio/README.md | 15 +++++++++- deploy/ai-studio/docker-compose.yml | 24 ++++++++++++---- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 6d250ca1d..85adb05e7 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -1,13 +1,21 @@ -# Copy to .env next to docker-compose.yml and fill in. Everything except -# OPENROUTER_API_KEY has a working default. +# Copy to .env next to docker-compose.yml and fill in. Every variable has a +# working default — the stack comes up without any of them. Set AI_API_KEY to +# enable AI Agent nodes; without it the stack runs and every other node type +# works, while AI Agent nodes fail with `ai_not_configured`. -# --- required --------------------------------------------------------------- +# --- LLM -------------------------------------------------------------------- + +# Server-side only; never reaches the browser. Pair it with a provider-side +# spend cap (hard $/day ceiling) — see README "Spend safety". +AI_API_KEY= -# Server-side only; never reaches the browser. Pair it with an OpenRouter -# account Guardrail (hard $/day ceiling) — see README "Spend safety". +# The former name of AI_API_KEY, still read when AI_API_KEY is empty. Existing +# deployments keep working; new ones should use AI_API_KEY. OPENROUTER_API_KEY= -# --- LLM -------------------------------------------------------------------- +# Any OpenAI-compatible endpoint. The default is OpenRouter; point it at a +# gateway or a model inside your own network and no LLM traffic leaves it. +AI_BASE_URL=https://openrouter.ai/api/v1 # Demo model. Cheap, EU-hosted, solid tool calling. # ~$0.075/M input + $0.20/M output => ~$0.0004 per 3-call template run. @@ -35,6 +43,29 @@ WEB_PORT=8080 # served from a different host than the backend. VITE_BACKEND_URL= +# --- temporal ----------------------------------------------------------------- + +# Leave these alone to use the bundled dev-grade cluster (see README "Known +# limitations"). To run against an operated cluster or Temporal Cloud instead, +# point them at it — the backend and the worker read the same values and must +# agree on the namespace. +# +# TEMPORAL_ADDRESS=..tmprl.cloud:7233 +# TEMPORAL_NAMESPACE=. +# TEMPORAL_API_KEY= +# +# TEMPORAL_TLS: empty infers (an API key turns TLS on by itself), `true` requires +# TLS with the OS trust store, `false` asserts plaintext. +TEMPORAL_ADDRESS=temporal:7233 +TEMPORAL_NAMESPACE=default +TEMPORAL_TLS= +TEMPORAL_API_KEY= + +# mTLS client certificates are configured with TEMPORAL_TLS_CA_PATH / +# _CERT_PATH / _KEY_PATH. They are not wired here because the PEM files have to +# be mounted into the backend and worker containers first — add the volumes and +# the three variables to docker-compose.yml if you need mTLS. + # --- databases (internal network only, not published) ------------------------- APP_DB_PASSWORD=wb diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index 530155ac3..22e118337 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -81,6 +81,18 @@ Swapping the LLM is a one-liner: change `AI_MODEL` to any [OpenRouter model id](https://openrouter.ai/models) and `docker compose up -d worker`. +**Pointing at a different LLM.** `AI_BASE_URL` takes any OpenAI-compatible +endpoint, so a gateway or a model hosted inside your own network works without +a code change — set it alongside `AI_API_KEY` and `AI_MODEL`. Leave `AI_API_KEY` +empty and the stack still comes up: every node type runs except AI Agent nodes, +which fail with `ai_not_configured`. + +**Pointing at a different Temporal.** `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, +`TEMPORAL_TLS` and `TEMPORAL_API_KEY` are passed to both the backend and the +worker, which is all an operated cluster or Temporal Cloud needs. mTLS uses +`TEMPORAL_TLS_CA_PATH` / `_CERT_PATH` / `_KEY_PATH`; those are not in this +compose because the PEM files have to be mounted into both containers first. + ## Operations ```bash @@ -110,6 +122,7 @@ fail. Deploys that leave the emit sequence alone are unaffected. See - **Single backend replica.** The rate limiter is process-local. Scaling out needs a shared store (Redis) — deferred to the scale-ready task. - **`temporalio/auto-setup` is dev-grade.** Fine for a demo; move to Temporal - Cloud or an operated cluster for sustained load. + Cloud or an operated cluster for sustained load. That move is configuration + only — see "Pointing at a different Temporal" above. - **Anyone-can-edit demo content.** Visitors share one workspace; data is wiped whenever you decide to recreate the volumes. diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 5eed67bf7..b6445d279 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -75,15 +75,22 @@ services: HOST: 0.0.0.0 PORT: 3001 DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder - TEMPORAL_ADDRESS: temporal:7233 + # point these at an external cluster or Temporal Cloud to retire the bundled one + TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233} + TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default} + TEMPORAL_TLS: ${TEMPORAL_TLS:-} + TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-} # explicit opt-in — a forgotten env var fails loudly instead of exposing the API WB_AUTH_PORT: allow-all # only nginx can reach the backend, so X-Forwarded-For is trustworthy TRUST_PROXY: 'true' RATE_LIMIT_EXECUTE_PER_MINUTE: ${RATE_LIMIT_EXECUTE_PER_MINUTE:-10} RATE_LIMIT_EXECUTE_PER_DAY: ${RATE_LIMIT_EXECUTE_PER_DAY:-50} - # the backend calls the LLM itself for /api/visualize/adapt - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} + # the backend calls the LLM itself for /api/visualize/adapt. Both key names are + # passed through; the app prefers AI_API_KEY and falls back to the older one. + AI_API_KEY: ${AI_API_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + AI_BASE_URL: ${AI_BASE_URL:-https://openrouter.ai/api/v1} AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} depends_on: app-db: @@ -111,8 +118,15 @@ services: command: ['pnpm', '--filter', 'execution-worker', 'start:prod'] environment: DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder - TEMPORAL_ADDRESS: temporal:7233 - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} + # must resolve to the same namespace the backend submits to + TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233} + TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default} + TEMPORAL_TLS: ${TEMPORAL_TLS:-} + TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-} + # empty is allowed: the worker starts and runs every node except AI Agent ones + AI_API_KEY: ${AI_API_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + AI_BASE_URL: ${AI_BASE_URL:-https://openrouter.ai/api/v1} AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} # optional - empty disables the AI Agent's web search tool TAVILY_API_KEY: ${TAVILY_API_KEY:-} From 9b468ed3a7c208347639cd0f20e704699968ea21 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 09:55:22 +0200 Subject: [PATCH 07/27] fix(config): drop the OpenRouter key alias and built-in LLM defaults OPENROUTER_API_KEY is no longer read. As an unconditional fallback for AI_API_KEY it was sent as a bearer token to whatever AI_BASE_URL pointed at, so an old .env plus a repointed endpoint leaked the OpenRouter credential. There are no external deployments to keep compatible; rename the variable instead. AI_BASE_URL and AI_MODEL lose their code and compose defaults too, so nothing in the code points outside the network. The OpenRouter values live in .env.example only. AI is configured when all three AI_* vars are set; otherwise the worker boots and names the missing ones, AI Agent nodes fail with ai_not_configured, and the adapt route returns 501. --- apps/backend/.env.example | 5 ++-- apps/backend/README.md | 2 +- apps/backend/src/env.test.ts | 29 +++++++++++-------- apps/backend/src/env.ts | 16 +++++----- apps/backend/src/routes/visualize.ts | 7 +++-- apps/execution-worker/.env.example | 5 ++-- apps/execution-worker/README.md | 15 +++++----- .../src/engines/temporal/worker.ts | 7 +++-- apps/execution-worker/src/env.test.ts | 25 +++++++++------- apps/execution-worker/src/env.ts | 18 +++++------- .../src/executors/ai-agent.test.ts | 18 ++++++++++++ .../src/executors/ai-agent.ts | 19 +++++++----- deploy/ai-studio/.env.example | 23 +++++++-------- deploy/ai-studio/README.md | 16 +++++----- deploy/ai-studio/docker-compose.yml | 15 ++++------ 15 files changed, 126 insertions(+), 94 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index eaa2ebfb6..cae5eeef5 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -39,10 +39,9 @@ TURNSTILE_SECRET_KEY= # API key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt). # Optional: leave empty to disable AI adapt (the endpoint returns 501). The # execution worker keeps its own key for running workflows. -# OPENROUTER_API_KEY is the former name and is still read when this is empty. -AI_API_KEY= +AI_API_KEY=sk-or-... # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own -# network. Must be the base URL, without a trailing /chat/completions. +# network. Must be the base URL, without a trailing /chat/completions. OpenRouter by default. AI_BASE_URL=https://openrouter.ai/api/v1 # Model id as the endpoint above understands it. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/apps/backend/README.md b/apps/backend/README.md index 9eb10671f..0cb30e12f 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -57,7 +57,7 @@ TEMPORAL_ADDRESS=127.0.0.1:7233 ``` Both also read `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` — all optional, and each side degrades -on its own when the key is missing: the backend's AI adapt endpoint returns 501, and the worker +on its own when any of them is missing: the backend's AI adapt endpoint returns 501, and the worker runs everything except AI Agent nodes. See [`apps/execution-worker/README.md`](../execution-worker/README.md). ### Connecting to a secured Temporal cluster diff --git a/apps/backend/src/env.test.ts b/apps/backend/src/env.test.ts index 493011ac9..5f151ec4c 100644 --- a/apps/backend/src/env.test.ts +++ b/apps/backend/src/env.test.ts @@ -15,30 +15,35 @@ afterEach(() => { }); describe('AI_API_KEY', () => { - it('takes AI_API_KEY when both names are set', async () => { - const env = await loadEnv({ AI_API_KEY: 'new-key', OPENROUTER_API_KEY: 'old-key' }); + it('reads AI_API_KEY', async () => { + const env = await loadEnv({ AI_API_KEY: 'key' }); - expect(env.AI_API_KEY).toBe('new-key'); + expect(env.AI_API_KEY).toBe('key'); }); - it('falls back to OPENROUTER_API_KEY so existing deployments keep working', async () => { - const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' }); + it('reads an empty value as unset', async () => { + const env = await loadEnv({ AI_API_KEY: '' }); - expect(env.AI_API_KEY).toBe('old-key'); + expect(env.AI_API_KEY).toBeNull(); }); - it('reads an empty value as unset on both names', async () => { - const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: '' }); + // The alias was dropped rather than scoped: a provider-named key that silently + // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are + // no external deployments to keep working. Rename the variable in .env instead. + it('does not read the retired OPENROUTER_API_KEY name', async () => { + const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' }); expect(env.AI_API_KEY).toBeNull(); }); }); -describe('AI_BASE_URL', () => { - it('defaults to OpenRouter', async () => { - const env = await loadEnv({}); +describe('AI_BASE_URL and AI_MODEL', () => { + // No built-in endpoint or model: the OpenRouter values live in .env.example only. + it('are null when unset', async () => { + const env = await loadEnv({ AI_BASE_URL: '', AI_MODEL: '' }); - expect(env.AI_BASE_URL).toBe('https://openrouter.ai/api/v1'); + expect(env.AI_BASE_URL).toBeNull(); + expect(env.AI_MODEL).toBeNull(); }); it('points at any OpenAI-compatible endpoint', async () => { diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index c39e507f9..9bf17c577 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -2,9 +2,8 @@ function envOr(name: string, defaultValue: string): string { return process.env[name] ?? defaultValue; } -// Empty string counts as unset. Compose passes absent optionals through as -// `${VAR:-}`, so a bare `?? null` would read '' as a configured value and, for -// the key below, shadow the fallback. +// Empty string counts as unset: compose passes absent optionals through as +// `${VAR:-}`, and a bare `?? null` would read '' as a configured value. function envOptional(name: string): string | null { return process.env[name] || null; } @@ -35,10 +34,11 @@ export const env = { TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true', // Null = Turnstile verification disabled (local dev runs unprotected). TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null, + // AI adapt needs all three; any missing one disables the endpoint (returns 501). + // No built-in endpoint or model — nothing in the code points outside the network. + // The worker keeps its own copies. + AI_API_KEY: envOptional('AI_API_KEY'), // Any OpenAI-compatible endpoint, including one inside your own network. - AI_BASE_URL: envOr('AI_BASE_URL', 'https://openrouter.ai/api/v1'), - // Null = the "AI adapt" endpoint is disabled (returns 501). The worker keeps its own key. - // OPENROUTER_API_KEY is the former name, still honoured so existing deployments keep working. - AI_API_KEY: envOptional('AI_API_KEY') ?? envOptional('OPENROUTER_API_KEY'), - AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), + AI_BASE_URL: envOptional('AI_BASE_URL'), + AI_MODEL: envOptional('AI_MODEL'), }; diff --git a/apps/backend/src/routes/visualize.ts b/apps/backend/src/routes/visualize.ts index 11d8586a7..2bf7ffe57 100644 --- a/apps/backend/src/routes/visualize.ts +++ b/apps/backend/src/routes/visualize.ts @@ -50,7 +50,8 @@ export function createVisualizeRoutes( return blocked; } - if (!env.AI_API_KEY) { + const { AI_API_KEY: apiKey, AI_BASE_URL: baseURL, AI_MODEL: modelId } = env; + if (!apiKey || !baseURL || !modelId) { return c.json({ code: 'adapt_disabled', message: 'AI adapt is not configured on this server.' }, 501); } @@ -61,9 +62,9 @@ export function createVisualizeRoutes( const { content, format } = parsed.data; try { - const provider = createOpenAICompatible({ name: 'ai', baseURL: env.AI_BASE_URL, apiKey: env.AI_API_KEY }); + const provider = createOpenAICompatible({ name: 'ai', baseURL, apiKey }); const result = await generateText({ - model: provider.chatModel(env.AI_MODEL), + model: provider.chatModel(modelId), system: FORMAT_PROMPTS[format], // Low temperature for stable structured output. temperature: 0.2, diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index 2011d2e98..d5e221664 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -23,10 +23,9 @@ TEMPORAL_TLS_KEY_PATH= # LLM for AI Agent nodes. Optional: leave empty and the worker still starts and # runs every other node type — AI Agent nodes then fail with `ai_not_configured`. -# OPENROUTER_API_KEY is the former name and is still read when this is empty. -AI_API_KEY= +AI_API_KEY=sk-or-... # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own -# network. Must be the base URL, without a trailing /chat/completions. +# network. Must be the base URL, without a trailing /chat/completions. OpenRouter by default. AI_BASE_URL=https://openrouter.ai/api/v1 # Model id as the endpoint above understands it. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index 7fea1f698..27982c42e 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -34,17 +34,18 @@ See `.env.example`. Every variable has a working default: | `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | | `TEMPORAL_NAMESPACE` | Namespace. Must match the backend's | `default` | | `AI_API_KEY` | LLM for AI Agent nodes (optional) | — (AI Agent nodes fail) | -| `AI_BASE_URL` | Any OpenAI-compatible endpoint | `https://openrouter.ai/api/v1` | -| `AI_MODEL` | Model id, as the endpoint spells it | `mistralai/mistral-small-3.2-24b-instruct` | +| `AI_BASE_URL` | Any OpenAI-compatible endpoint | — (AI Agent nodes fail) | +| `AI_MODEL` | Model id, as the endpoint spells it | — (AI Agent nodes fail) | | `TAVILY_API_KEY` | AI Agent's web-search tool (optional) | — (tool disabled) | -`AI_API_KEY` is optional by design: the worker boots without it and runs every non-AI node, and -an AI Agent node that is reached fails with the `ai_not_configured` code rather than taking the -whole worker down. `OPENROUTER_API_KEY` is the former name of this variable and is still read -when `AI_API_KEY` is empty. +The three `AI_*` variables are optional by design: the worker boots without them and runs every +non-AI node, and an AI Agent node that is reached fails with the `ai_not_configured` code rather +than taking the whole worker down. `AI_API_KEY` was previously called `OPENROUTER_API_KEY`; the +old name is no longer read. Point `AI_BASE_URL` at any OpenAI-compatible server — a gateway, or a model hosted inside your -own network — and no request leaves that network. +own network — and no request leaves that network. There is no built-in endpoint or model: +`.env.example` pre-fills the OpenRouter values the worker used before they became configurable. The connection to Temporal is env-driven too: `TEMPORAL_TLS`, `TEMPORAL_API_KEY` and the `TEMPORAL_TLS_CA_PATH` / `TEMPORAL_TLS_CERT_PATH` / `TEMPORAL_TLS_KEY_PATH` trio cover a hardened diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index f03d17933..118ae7408 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -14,8 +14,11 @@ import { logger } from '../../logger'; import { withPayloadSizeWarning } from '../../store-payload-warning'; import { buildTemporalConnectionOptions } from './temporal-connection'; -if (!env.AI_API_KEY) { - logger.warn('no LLM key configured — AI Agent nodes will fail; every other node type runs as usual'); +const missingAiConfig = (['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] as const).filter((name) => !env[name]); +if (missingAiConfig.length > 0) { + logger.warn('AI not configured — AI Agent nodes will fail; every other node type runs as usual', { + missing: missingAiConfig, + }); } const executeAIAgent = createAiAgentExecutor({ diff --git a/apps/execution-worker/src/env.test.ts b/apps/execution-worker/src/env.test.ts index 2e212e510..25ebcc4f7 100644 --- a/apps/execution-worker/src/env.test.ts +++ b/apps/execution-worker/src/env.test.ts @@ -18,29 +18,34 @@ describe('AI_API_KEY', () => { // The worker used to throw at module load without a key. Booting keyless is the // point: a deployment that runs no AI nodes should not need an LLM account. it('is null when unset, rather than refusing to load', async () => { - const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: '' }); + const env = await loadEnv({ AI_API_KEY: '' }); expect(env.AI_API_KEY).toBeNull(); }); - it('takes AI_API_KEY when both names are set', async () => { - const env = await loadEnv({ AI_API_KEY: 'new-key', OPENROUTER_API_KEY: 'old-key' }); + it('reads AI_API_KEY', async () => { + const env = await loadEnv({ AI_API_KEY: 'key' }); - expect(env.AI_API_KEY).toBe('new-key'); + expect(env.AI_API_KEY).toBe('key'); }); - it('falls back to OPENROUTER_API_KEY so existing deployments keep working', async () => { + // The alias was dropped rather than scoped: a provider-named key that silently + // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are + // no external deployments to keep working. Rename the variable in .env instead. + it('does not read the retired OPENROUTER_API_KEY name', async () => { const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' }); - expect(env.AI_API_KEY).toBe('old-key'); + expect(env.AI_API_KEY).toBeNull(); }); }); -describe('AI_BASE_URL', () => { - it('defaults to OpenRouter', async () => { - const env = await loadEnv({}); +describe('AI_BASE_URL and AI_MODEL', () => { + // No built-in endpoint or model: the OpenRouter values live in .env.example only. + it('are null when unset', async () => { + const env = await loadEnv({ AI_BASE_URL: '', AI_MODEL: '' }); - expect(env.AI_BASE_URL).toBe('https://openrouter.ai/api/v1'); + expect(env.AI_BASE_URL).toBeNull(); + expect(env.AI_MODEL).toBeNull(); }); it('points at any OpenAI-compatible endpoint', async () => { diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index beff3da6d..87df5a83a 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -2,9 +2,8 @@ function envOr(name: string, defaultValue: string): string { return process.env[name] ?? defaultValue; } -// Empty string counts as unset. Compose passes absent optionals through as -// `${VAR:-}`, so a bare `?? null` would read '' as a configured value and, for -// the key below, shadow the fallback. +// Empty string counts as unset: compose passes absent optionals through as +// `${VAR:-}`, and a bare `?? null` would read '' as a configured value. function envOptional(name: string): string | null { return process.env[name] || null; } @@ -25,14 +24,13 @@ export const env = { TEMPORAL_TLS_CA_PATH: envOptional('TEMPORAL_TLS_CA_PATH'), TEMPORAL_TLS_CERT_PATH: envOptional('TEMPORAL_TLS_CERT_PATH'), TEMPORAL_TLS_KEY_PATH: envOptional('TEMPORAL_TLS_KEY_PATH'), + // AI Agent nodes need all three. Any missing one: the worker still boots and runs + // every non-AI node; AI Agent nodes fail with `ai_not_configured` when reached. + // No built-in endpoint or model — nothing in the code points outside the network. + AI_API_KEY: envOptional('AI_API_KEY'), // Any OpenAI-compatible endpoint, including one inside your own network. - AI_BASE_URL: envOr('AI_BASE_URL', 'https://openrouter.ai/api/v1'), - // Null = the worker still boots and runs every non-AI node; AI Agent nodes - // fail with `ai_not_configured` when reached. OPENROUTER_API_KEY is the - // former name, still honoured so existing deployments keep working. - AI_API_KEY: envOptional('AI_API_KEY') ?? envOptional('OPENROUTER_API_KEY'), - // Cheap, fast default for the public demo; quality-per-cost over frontier capability. - AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), + AI_BASE_URL: envOptional('AI_BASE_URL'), + AI_MODEL: envOptional('AI_MODEL'), // Optional. Enables the AI Agent's web-search tool; agents run without it when unset. TAVILY_API_KEY: process.env['TAVILY_API_KEY'], }; diff --git a/apps/execution-worker/src/executors/ai-agent.test.ts b/apps/execution-worker/src/executors/ai-agent.test.ts index 652c015e6..cea8fec91 100644 --- a/apps/execution-worker/src/executors/ai-agent.test.ts +++ b/apps/execution-worker/src/executors/ai-agent.test.ts @@ -54,3 +54,21 @@ describe('createAiAgentExecutor with a key', () => { expect(executor).toBeTypeOf('function'); }); }); + +describe('createAiAgentExecutor with a key but no endpoint or model', () => { + // Neither has a built-in default, so they gate the node exactly like the key does. + it('fails the node with the same code and names only the missing variables', () => { + const executor = createAiAgentExecutor({ apiKey: 'key', baseURL: null, modelId: null }); + + try { + executor(node, context()); + expect.unreachable('executor should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NodeExecutionError); + expect((error as NodeExecutionError).code).toBe('ai_not_configured'); + expect((error as NodeExecutionError).message).toContain('AI_BASE_URL'); + expect((error as NodeExecutionError).message).toContain('AI_MODEL'); + expect((error as NodeExecutionError).message).not.toContain('AI_API_KEY'); + } + }); +}); diff --git a/apps/execution-worker/src/executors/ai-agent.ts b/apps/execution-worker/src/executors/ai-agent.ts index 5ccf6749b..9d623d38a 100644 --- a/apps/execution-worker/src/executors/ai-agent.ts +++ b/apps/execution-worker/src/executors/ai-agent.ts @@ -1,4 +1,4 @@ -// Builds the AI Agent executor, and decides what happens when no LLM key is set. +// Builds the AI Agent executor, and decides what happens when the LLM is not configured. import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { type LoggerPort, NodeExecutionError, type NodeExecutor } from '@workflow-builder/execution-core'; @@ -7,11 +7,11 @@ import { executeAiAgent } from '../activities/ai-agent'; import type { AiAgentNode } from '../domain/ai-studio-nodes'; type AiAgentExecutorOptions = { - // Null when no key is configured. The worker still boots; only this node type + // Each null when not configured. The worker still boots; only this node type // is unavailable, so a graph of Trigger/Decision/Visualize nodes runs fine. apiKey: string | null; - baseURL: string; - modelId: string; + baseURL: string | null; + modelId: string | null; logger?: LoggerPort; tavilyApiKey?: string; }; @@ -19,15 +19,20 @@ type AiAgentExecutorOptions = { export function createAiAgentExecutor(options: AiAgentExecutorOptions): NodeExecutor { const { apiKey, baseURL, modelId, logger, tavilyApiKey } = options; - if (!apiKey) { - // Thrown when the node is reached rather than at boot, so a missing key + if (!apiKey || !baseURL || !modelId) { + const missing = [ + ...(apiKey ? [] : ['AI_API_KEY']), + ...(baseURL ? [] : ['AI_BASE_URL']), + ...(modelId ? [] : ['AI_MODEL']), + ].join(', '); + // Thrown when the node is reached rather than at boot, so missing config // costs one failed node instead of the whole worker. The node activity // profile retries it once — harmless, since nothing here can succeed on a // second attempt. return () => { throw new NodeExecutionError( 'ai_not_configured', - 'AI is not configured on this worker — set AI_API_KEY (see apps/execution-worker/.env.example).', + `AI is not configured on this worker — set ${missing} (see apps/execution-worker/.env.example).`, ); }; } diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 85adb05e7..1c24d11fc 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -1,23 +1,22 @@ -# Copy to .env next to docker-compose.yml and fill in. Every variable has a -# working default — the stack comes up without any of them. Set AI_API_KEY to -# enable AI Agent nodes; without it the stack runs and every other node type -# works, while AI Agent nodes fail with `ai_not_configured`. +# Copy to .env next to docker-compose.yml and fill in. The stack comes up with +# none of these set. AI Agent nodes need AI_API_KEY, AI_BASE_URL and AI_MODEL; +# without them the stack runs and every other node type works, while AI Agent +# nodes fail with `ai_not_configured`. # --- LLM -------------------------------------------------------------------- # Server-side only; never reaches the browser. Pair it with a provider-side # spend cap (hard $/day ceiling) — see README "Spend safety". -AI_API_KEY= +AI_API_KEY=sk-or-... -# The former name of AI_API_KEY, still read when AI_API_KEY is empty. Existing -# deployments keep working; new ones should use AI_API_KEY. -OPENROUTER_API_KEY= - -# Any OpenAI-compatible endpoint. The default is OpenRouter; point it at a -# gateway or a model inside your own network and no LLM traffic leaves it. +# Any OpenAI-compatible endpoint. There is no built-in default: the value below +# is the OpenRouter setup the stack used before the endpoint became configurable. +# Point it at a gateway or a model inside your own network and no LLM traffic +# leaves it. OpenRouter by default. AI_BASE_URL=https://openrouter.ai/api/v1 -# Demo model. Cheap, EU-hosted, solid tool calling. +# Model id as the endpoint above understands it. This one +# is the demo pick: cheap, EU-hosted, solid tool calling. # ~$0.075/M input + $0.20/M output => ~$0.0004 per 3-call template run. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index 22e118337..08b76fb6e 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -25,7 +25,7 @@ service or step. ```bash cd deploy/ai-studio -cp .env.example .env # set OPENROUTER_API_KEY +cp .env.example .env # set AI_API_KEY to enable AI Agent nodes docker compose up -d --build ``` @@ -77,15 +77,17 @@ this compose never publishes them; don't undo that. ## Configuration See [.env.example](.env.example) — every variable is documented there. -Swapping the LLM is a one-liner: change `AI_MODEL` to any -[OpenRouter model id](https://openrouter.ai/models) and -`docker compose up -d worker`. +Swapping the model is a one-liner: change `AI_MODEL` to any id the endpoint +understands (for OpenRouter, an [OpenRouter model id](https://openrouter.ai/models)) +and `docker compose up -d worker`. **Pointing at a different LLM.** `AI_BASE_URL` takes any OpenAI-compatible endpoint, so a gateway or a model hosted inside your own network works without -a code change — set it alongside `AI_API_KEY` and `AI_MODEL`. Leave `AI_API_KEY` -empty and the stack still comes up: every node type runs except AI Agent nodes, -which fail with `ai_not_configured`. +a code change — set it alongside `AI_API_KEY` and `AI_MODEL`. None of the three +has a built-in default; `.env.example` pre-fills the OpenRouter values the stack +used before the endpoint became configurable. Leave any of them empty and the +stack still comes up: every node type runs except AI Agent nodes, which fail +with `ai_not_configured`. **Pointing at a different Temporal.** `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_TLS` and `TEMPORAL_API_KEY` are passed to both the backend and the diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index b6445d279..3fedd9964 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -1,5 +1,5 @@ # AI Studio production stack (WB-229). Usage: cp .env.example .env, set -# OPENROUTER_API_KEY, then `docker compose up -d --build`. Only `web` +# AI_API_KEY, then `docker compose up -d --build`. Only `web` # publishes a port. name: ai-studio @@ -86,12 +86,10 @@ services: TRUST_PROXY: 'true' RATE_LIMIT_EXECUTE_PER_MINUTE: ${RATE_LIMIT_EXECUTE_PER_MINUTE:-10} RATE_LIMIT_EXECUTE_PER_DAY: ${RATE_LIMIT_EXECUTE_PER_DAY:-50} - # the backend calls the LLM itself for /api/visualize/adapt. Both key names are - # passed through; the app prefers AI_API_KEY and falls back to the older one. + # the backend calls the LLM itself for /api/visualize/adapt AI_API_KEY: ${AI_API_KEY:-} - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} - AI_BASE_URL: ${AI_BASE_URL:-https://openrouter.ai/api/v1} - AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} + AI_BASE_URL: ${AI_BASE_URL:-} + AI_MODEL: ${AI_MODEL:-} depends_on: app-db: condition: service_healthy @@ -125,9 +123,8 @@ services: TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-} # empty is allowed: the worker starts and runs every node except AI Agent ones AI_API_KEY: ${AI_API_KEY:-} - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} - AI_BASE_URL: ${AI_BASE_URL:-https://openrouter.ai/api/v1} - AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} + AI_BASE_URL: ${AI_BASE_URL:-} + AI_MODEL: ${AI_MODEL:-} # optional - empty disables the AI Agent's web search tool TAVILY_API_KEY: ${TAVILY_API_KEY:-} depends_on: From be9bf5b68b59ce20807e78c8a707c6fda83c0c81 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 12:08:40 +0200 Subject: [PATCH 08/27] fix(execution-worker): make ai_not_configured a permanent failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing AI configuration cannot recover on retry, yet the plain NodeExecutionError was retried once and lost its code crossing the activity boundary — node_failed carried only the message. Thrown as PermanentNodeExecutionError it stops on the first attempt and the code survives via the classified-error envelope. Adds a test through a real Temporal dev server asserting the node_failed code, a single attempt, and the workflow's failure type; the unclassified path is pinned alongside as the contrast. --- apps/execution-worker/README.md | 2 +- .../src/executors/ai-agent.test.ts | 17 ++- .../src/executors/ai-agent.ts | 9 +- packages/temporal/test/error-boundary.test.ts | 129 ++++++++++++++++++ packages/temporal/test/fixtures/graph.ts | 6 +- 5 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 packages/temporal/test/error-boundary.test.ts diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index 27982c42e..78059ff0c 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -74,7 +74,7 @@ own: one executor per node type and the database as the store port. - **Namespace:** `TEMPORAL_NAMESPACE`, default `default`. Unlike the task queue this is _not_ shared through the plugin, so the two apps have to be configured to agree — a mismatch is silent, the worker simply never sees the backend's submissions. - **Workflow ID:** `execution-` — deterministic, lets the backend cancel by execution ID. Also owned by the package. - **Activity timeouts:** DB activities get 30s / 5 retries; node activities (may call LLMs) get 10m / 2 retries. Exported as `DEFAULT_DATABASE_ACTIVITY_PROFILE` and `DEFAULT_NODE_ACTIVITY_PROFILE`. -- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior — the reference executors have not been classified yet. +- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior. Of the reference executors, only the AI Agent's `ai_not_configured` is classified (permanent) so far; the rest are still unclassified. - **Sandbox constraint:** `workflows.ts` is bundled into V8 with no Web APIs. It may only re-export from `@workflowbuilder/temporal/workflow`, never from the package root. - **Editing the package:** the worker imports its built `dist`, so run `pnpm build:temporal` after changing `packages/temporal/src`. - **Deploys that change the emitted event set:** drain in-flight runs first. Replaying an old run's history against a new emit sequence diverges — see [`replay-audit.md`](../../packages/execution-core/replay-audit.md) rule 9. diff --git a/apps/execution-worker/src/executors/ai-agent.test.ts b/apps/execution-worker/src/executors/ai-agent.test.ts index cea8fec91..845528b54 100644 --- a/apps/execution-worker/src/executors/ai-agent.test.ts +++ b/apps/execution-worker/src/executors/ai-agent.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { type ExecutionContext, NodeExecutionError } from '@workflow-builder/execution-core'; +import { + type ExecutionContext, + NodeExecutionError, + PermanentNodeExecutionError, + classifyNodeError, +} from '@workflow-builder/execution-core'; import type { AiAgentNode } from '../domain/ai-studio-nodes'; import { createAiAgentExecutor } from './ai-agent'; @@ -43,6 +48,16 @@ describe('createAiAgentExecutor without a key', () => { expect((error as NodeExecutionError).message).toContain('AI_API_KEY'); } }); + + it('is permanent, so the engine adapter stops after one attempt', () => { + try { + executor(node, context()); + expect.unreachable('executor should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(PermanentNodeExecutionError); + expect(classifyNodeError(error)).toBe('permanent'); + } + }); }); describe('createAiAgentExecutor with a key', () => { diff --git a/apps/execution-worker/src/executors/ai-agent.ts b/apps/execution-worker/src/executors/ai-agent.ts index 9d623d38a..1f232c90d 100644 --- a/apps/execution-worker/src/executors/ai-agent.ts +++ b/apps/execution-worker/src/executors/ai-agent.ts @@ -1,7 +1,7 @@ // Builds the AI Agent executor, and decides what happens when the LLM is not configured. import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { type LoggerPort, NodeExecutionError, type NodeExecutor } from '@workflow-builder/execution-core'; +import { type LoggerPort, type NodeExecutor, PermanentNodeExecutionError } from '@workflow-builder/execution-core'; import { executeAiAgent } from '../activities/ai-agent'; import type { AiAgentNode } from '../domain/ai-studio-nodes'; @@ -26,11 +26,10 @@ export function createAiAgentExecutor(options: AiAgentExecutorOptions): NodeExec ...(modelId ? [] : ['AI_MODEL']), ].join(', '); // Thrown when the node is reached rather than at boot, so missing config - // costs one failed node instead of the whole worker. The node activity - // profile retries it once — harmless, since nothing here can succeed on a - // second attempt. + // costs one failed node instead of the whole worker. Permanent: a retry + // cannot find configuration that is not there. return () => { - throw new NodeExecutionError( + throw new PermanentNodeExecutionError( 'ai_not_configured', `AI is not configured on this worker — set ${missing} (see apps/execution-worker/.env.example).`, ); diff --git a/packages/temporal/test/error-boundary.test.ts b/packages/temporal/test/error-boundary.test.ts new file mode 100644 index 000000000..ce08fea5e --- /dev/null +++ b/packages/temporal/test/error-boundary.test.ts @@ -0,0 +1,129 @@ +// Runs graphs through a real Temporal dev server, so every assertion crosses the actual +// activity → workflow boundary where a thrown error is serialized and its class is lost. +import { WorkflowFailedError } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker, bundleWorkflowCode } from '@temporalio/worker'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + type BaseNode, + DEFAULT_NODE_ACTIVITY_PROFILE, + NodeExecutionError, + type NodeExecutorRegistry, + PermanentNodeExecutionError, + RUN_WORKFLOW_NAME, + WorkflowBuilderPlugin, + type WorkflowDefinition, + type WorkflowExecutionInput, + executionWorkflowId, +} from '../src/index'; +import { type RecordingStore, createRecordingStore } from './fixtures/graph'; + +type BoundaryNode = (BaseNode & { type: 'test/step' }) | (BaseNode & { type: 'test/fail' }); + +const TASK_QUEUE = 'error-boundary-test'; + +function graph(workflowId: string): WorkflowDefinition { + return { + workflowId, + nodes: [ + { id: 'start', type: 'test/step', role: 'start', config: {} }, + { id: 'fail', type: 'test/fail', config: {} }, + ], + edges: [{ id: 'e-start-fail', sourceNodeId: 'start', targetNodeId: 'fail' }], + }; +} + +type Run = { store: RecordingStore; attempts: number; failure: unknown }; + +function nodeFailedPayload(store: RecordingStore): unknown { + return store.events.find((event) => event.type === 'node_failed' && event.nodeId === 'fail')?.payload; +} + +describe('error classification across the activity boundary', () => { + let env: TestWorkflowEnvironment; + let workflowBundle: { code: string }; + + beforeAll(async () => { + [workflowBundle, env] = await Promise.all([ + bundleWorkflowCode({ workflowsPath: fileURLToPath(new URL('fixtures/workflows.ts', import.meta.url)) }), + TestWorkflowEnvironment.createLocal(), + ]); + }, 300_000); + + afterAll(async () => { + await env?.teardown(); + }); + + async function run(executionId: string, thrown: () => Error): Promise { + const store = createRecordingStore(); + let attempts = 0; + + const executors: NodeExecutorRegistry = { + 'test/step': () => ({ output: null }), + 'test/fail': () => { + attempts += 1; + throw thrown(); + }, + }; + + const plugin = new WorkflowBuilderPlugin({ store, executors, taskQueue: TASK_QUEUE }); + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: plugin.taskQueue, + workflowBundle, + plugins: [plugin], + }); + + const workflowId = `wf-${executionId}`; + const input: WorkflowExecutionInput = { + workflowId, + executionId, + definition: graph(workflowId), + triggerPayload: {}, + variables: {}, + global: {}, + }; + + const failure = await worker.runUntil( + env.client.workflow + .execute(RUN_WORKFLOW_NAME, { + taskQueue: plugin.taskQueue, + workflowId: executionWorkflowId(executionId), + args: [input], + }) + .catch((error: unknown) => error), + ); + + return { store, attempts, failure }; + } + + it('a permanent throw stops on its first attempt and reaches node_failed with its code', async () => { + const { store, attempts, failure } = await run( + 'permanent', + () => new PermanentNodeExecutionError('ai_not_configured', 'AI is not configured on this worker'), + ); + + expect(attempts).toBe(1); + expect(nodeFailedPayload(store)).toEqual({ + error: { message: 'AI is not configured on this worker', code: 'ai_not_configured', attempt: 1 }, + }); + expect(store.statuses.at(-1)).toMatchObject({ status: 'failed' }); + + // The code also names the workflow's terminal failure type. + expect(failure).toBeInstanceOf(WorkflowFailedError); + expect((failure as WorkflowFailedError).cause).toMatchObject({ type: 'ai_not_configured' }); + }, 60_000); + + it('an unclassified throw retries per the profile and is reported exactly as before', async () => { + const { store, attempts } = await run( + 'unclassified', + () => new NodeExecutionError('no_branch_matched', 'No branch matched'), + ); + + expect(attempts).toBe(DEFAULT_NODE_ACTIVITY_PROFILE.retry.maximumAttempts); + expect(nodeFailedPayload(store)).toEqual({ error: { message: 'No branch matched' } }); + }, 60_000); +}); diff --git a/packages/temporal/test/fixtures/graph.ts b/packages/temporal/test/fixtures/graph.ts index 215bf2429..fd73b1755 100644 --- a/packages/temporal/test/fixtures/graph.ts +++ b/packages/temporal/test/fixtures/graph.ts @@ -33,7 +33,7 @@ export const replayTestExecutors: NodeExecutorRegistry = { }; export type RecordingStore = ExecutionStore & { - events: { sequence: number; type: string; nodeId?: string }[]; + events: { sequence: number; type: string; nodeId?: string; payload?: unknown }[]; statuses: { status: string; errorMessage?: string }[]; }; @@ -44,8 +44,8 @@ export function createRecordingStore(): RecordingStore { return { events, statuses, - async emitExecutionEvent(_executionId, sequence, type, _payload, nodeId) { - events.push({ sequence, type, nodeId }); + async emitExecutionEvent(_executionId, sequence, type, payload, nodeId) { + events.push({ sequence, type, nodeId, payload }); }, async updateExecutionStatus(_executionId, status, errorMessage) { statuses.push({ status, errorMessage }); From 9a336bc2d5669d42b9afe57a4f778e6268d7c07f Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 12:39:28 +0200 Subject: [PATCH 09/27] feat(deploy): pass Temporal TLS paths through compose and mount ./tls The apps already read TEMPORAL_TLS_CA_PATH / _CERT_PATH / _KEY_PATH, but compose passed none of them and the docs told users to edit the manifest. Both services now take every TEMPORAL_* variable from one shared YAML block, so they cannot drift, and mount ./tls (override via TEMPORAL_TLS_DIR) read-only at /etc/workflowbuilder/tls. The directory ships empty with a .gitignore so PEMs never reach git. --- deploy/ai-studio/.env.example | 18 ++++++++++++---- deploy/ai-studio/README.md | 13 +++++++----- deploy/ai-studio/docker-compose.yml | 32 ++++++++++++++++++++--------- deploy/ai-studio/tls/.gitignore | 4 ++++ 4 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 deploy/ai-studio/tls/.gitignore diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 1c24d11fc..bc127b5f5 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -60,10 +60,20 @@ TEMPORAL_NAMESPACE=default TEMPORAL_TLS= TEMPORAL_API_KEY= -# mTLS client certificates are configured with TEMPORAL_TLS_CA_PATH / -# _CERT_PATH / _KEY_PATH. They are not wired here because the PEM files have to -# be mounted into the backend and worker containers first — add the volumes and -# the three variables to docker-compose.yml if you need mTLS. +# Private CA or mTLS. Drop the PEM files into ./tls (or point TEMPORAL_TLS_DIR at +# another directory); both containers see it read-only at /etc/workflowbuilder/tls, +# so the three paths below are container paths: +# +# TEMPORAL_TLS_CA_PATH=/etc/workflowbuilder/tls/ca.pem +# TEMPORAL_TLS_CERT_PATH=/etc/workflowbuilder/tls/client.pem +# TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem +# +# CA alone covers a private issuer without client auth. Cert and key go together, +# and a client certificate excludes TEMPORAL_API_KEY. +TEMPORAL_TLS_DIR=./tls +TEMPORAL_TLS_CA_PATH= +TEMPORAL_TLS_CERT_PATH= +TEMPORAL_TLS_KEY_PATH= # --- databases (internal network only, not published) ------------------------- diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index 08b76fb6e..fa7e7519e 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -89,11 +89,14 @@ used before the endpoint became configurable. Leave any of them empty and the stack still comes up: every node type runs except AI Agent nodes, which fail with `ai_not_configured`. -**Pointing at a different Temporal.** `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, -`TEMPORAL_TLS` and `TEMPORAL_API_KEY` are passed to both the backend and the -worker, which is all an operated cluster or Temporal Cloud needs. mTLS uses -`TEMPORAL_TLS_CA_PATH` / `_CERT_PATH` / `_KEY_PATH`; those are not in this -compose because the PEM files have to be mounted into both containers first. +**Pointing at a different Temporal.** Every `TEMPORAL_*` variable reaches the +backend and the worker from one shared block in the compose file, so the two +cannot disagree. `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_TLS` and +`TEMPORAL_API_KEY` are all an operated cluster or Temporal Cloud needs. For a +private CA or mTLS, drop the PEM files into [`tls/`](tls/) (git-ignored, mounted +read-only into both containers at `/etc/workflowbuilder/tls`) and set +`TEMPORAL_TLS_CA_PATH` / `_CERT_PATH` / `_KEY_PATH` to those container paths — +see [.env.example](.env.example) for the exact lines. ## Operations diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 3fedd9964..930ab9c32 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -9,6 +9,24 @@ x-runtime-build: &runtime-build dockerfile: deploy/ai-studio/Dockerfile target: runtime +# Shared by backend and worker via YAML merge, so the two can never drift apart: +# the namespace must match or the worker polls a queue nobody submits to. Point +# these at an operated cluster or Temporal Cloud to retire the bundled one. +x-temporal-env: &temporal-env + TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233} + TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default} + TEMPORAL_TLS: ${TEMPORAL_TLS:-} + TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-} + # container paths under the mount below — see .env.example + TEMPORAL_TLS_CA_PATH: ${TEMPORAL_TLS_CA_PATH:-} + TEMPORAL_TLS_CERT_PATH: ${TEMPORAL_TLS_CERT_PATH:-} + TEMPORAL_TLS_KEY_PATH: ${TEMPORAL_TLS_KEY_PATH:-} + +# PEM files for a private CA or mTLS. ./tls ships empty (and git-ignored) so the +# mount always resolves; plaintext deployments never touch it. +x-temporal-tls-volumes: &temporal-tls-volumes + - ${TEMPORAL_TLS_DIR:-./tls}:/etc/workflowbuilder/tls:ro + services: app-db: image: postgres:16 @@ -72,14 +90,10 @@ services: build: *runtime-build command: ['pnpm', '--filter', 'backend', 'start:prod'] environment: + <<: *temporal-env HOST: 0.0.0.0 PORT: 3001 DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder - # point these at an external cluster or Temporal Cloud to retire the bundled one - TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233} - TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default} - TEMPORAL_TLS: ${TEMPORAL_TLS:-} - TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-} # explicit opt-in — a forgotten env var fails loudly instead of exposing the API WB_AUTH_PORT: allow-all # only nginx can reach the backend, so X-Forwarded-For is trustworthy @@ -90,6 +104,7 @@ services: AI_API_KEY: ${AI_API_KEY:-} AI_BASE_URL: ${AI_BASE_URL:-} AI_MODEL: ${AI_MODEL:-} + volumes: *temporal-tls-volumes depends_on: app-db: condition: service_healthy @@ -115,18 +130,15 @@ services: build: *runtime-build command: ['pnpm', '--filter', 'execution-worker', 'start:prod'] environment: + <<: *temporal-env DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder - # must resolve to the same namespace the backend submits to - TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233} - TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default} - TEMPORAL_TLS: ${TEMPORAL_TLS:-} - TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-} # empty is allowed: the worker starts and runs every node except AI Agent ones AI_API_KEY: ${AI_API_KEY:-} AI_BASE_URL: ${AI_BASE_URL:-} AI_MODEL: ${AI_MODEL:-} # optional - empty disables the AI Agent's web search tool TAVILY_API_KEY: ${TAVILY_API_KEY:-} + volumes: *temporal-tls-volumes depends_on: app-db: condition: service_healthy diff --git a/deploy/ai-studio/tls/.gitignore b/deploy/ai-studio/tls/.gitignore new file mode 100644 index 000000000..75f8fade2 --- /dev/null +++ b/deploy/ai-studio/tls/.gitignore @@ -0,0 +1,4 @@ +# Mounted read-only into the backend and worker as /etc/workflowbuilder/tls. +# Certificates and keys dropped here must never reach git. +* +!.gitignore From e94778537a848bd1588c906288171506f35e6a64 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 13:16:09 +0200 Subject: [PATCH 10/27] feat(deploy): let an external Temporal retire the bundled cluster Setting TEMPORAL_ADDRESS to an operated cluster or Temporal Cloud still started temporal and temporal-db, and the apps' depends_on edges let that unused stack block them. The bundled cluster, its volume, its debug UI and the start-order edges now live in docker-compose.override.yml, applied by default; COMPOSE_FILE=docker-compose.yml in .env leaves it out, so the apps depend only on app-db. The debug UI is documented as showing the bundled cluster only. --- deploy/ai-studio/.env.example | 8 ++- deploy/ai-studio/README.md | 19 ++++-- deploy/ai-studio/docker-compose.override.yml | 61 ++++++++++++++++++++ deploy/ai-studio/docker-compose.yml | 54 ++--------------- 4 files changed, 89 insertions(+), 53 deletions(-) create mode 100644 deploy/ai-studio/docker-compose.override.yml diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index bc127b5f5..ad4532384 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -47,12 +47,18 @@ VITE_BACKEND_URL= # Leave these alone to use the bundled dev-grade cluster (see README "Known # limitations"). To run against an operated cluster or Temporal Cloud instead, # point them at it — the backend and the worker read the same values and must -# agree on the namespace. +# agree on the namespace — and set COMPOSE_FILE so the bundled cluster is not +# started at all (it lives in docker-compose.override.yml, which compose applies +# by default; the apps then depend only on app-db): # +# COMPOSE_FILE=docker-compose.yml # TEMPORAL_ADDRESS=..tmprl.cloud:7233 # TEMPORAL_NAMESPACE=. # TEMPORAL_API_KEY= # +# Run `docker compose down --remove-orphans` once when switching, so the retired +# temporal containers from the bundled setup are removed. +# # TEMPORAL_TLS: empty infers (an API key turns TLS on by itself), `true` requires # TLS with the OS trust store, `false` asserts plaintext. TEMPORAL_ADDRESS=temporal:7233 diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index fa7e7519e..c922d2e60 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -15,6 +15,12 @@ any Docker host — an Azure VM, AWS, on-prem — with no cloud-specific glue. | `temporal-db` | `postgres:16` | Temporal's own state store | internal | | `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | +The three Temporal rows come from +[`docker-compose.override.yml`](docker-compose.override.yml), which compose +applies on top of [`docker-compose.yml`](docker-compose.yml) by default. The base +file alone has no cluster: the apps connect to whatever `TEMPORAL_ADDRESS` names +and depend only on `app-db` — see "Pointing at a different Temporal". + Both images build from one Dockerfile (`deploy/ai-studio/Dockerfile`) with the repo root as context. Backend and worker share a single image and differ only in the compose `command`. Database migrations are applied by the backend at @@ -92,8 +98,13 @@ with `ai_not_configured`. **Pointing at a different Temporal.** Every `TEMPORAL_*` variable reaches the backend and the worker from one shared block in the compose file, so the two cannot disagree. `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_TLS` and -`TEMPORAL_API_KEY` are all an operated cluster or Temporal Cloud needs. For a -private CA or mTLS, drop the PEM files into [`tls/`](tls/) (git-ignored, mounted +`TEMPORAL_API_KEY` are all an operated cluster or Temporal Cloud needs. Add +`COMPOSE_FILE=docker-compose.yml` to `.env` at the same time: it leaves the +override file out, so the bundled cluster is not started and cannot block the +apps, and `backend` / `worker` depend only on `app-db`. Run +`docker compose down --remove-orphans` once when switching. The bundled debug +UI (`--profile debug`) is part of the override and only ever shows the bundled +cluster — an external cluster has its own UI. For a private CA or mTLS, drop the PEM files into [`tls/`](tls/) (git-ignored, mounted read-only into both containers at `/etc/workflowbuilder/tls`) and set `TEMPORAL_TLS_CA_PATH` / `_CERT_PATH` / `_KEY_PATH` to those container paths — see [.env.example](.env.example) for the exact lines. @@ -115,8 +126,8 @@ volumes is acceptable; there is nothing precious in them. emitted**, let in-flight executions finish. Temporal replays a running workflow's history against the deployed code, so a run started on the old emit sequence diverges when replayed on the new one. Check for active runs in -the Temporal UI (`--profile debug`), or accept that any still running will -fail. Deploys that leave the emit sequence alone are unaffected. See +the Temporal UI (`--profile debug` for the bundled cluster, your cluster's own UI +otherwise), or accept that any still running will fail. Deploys that leave the emit sequence alone are unaffected. See [`replay-audit.md`](../../packages/execution-core/replay-audit.md) rule 9. ## Known limitations (accepted for the lean MVP) diff --git a/deploy/ai-studio/docker-compose.override.yml b/deploy/ai-studio/docker-compose.override.yml new file mode 100644 index 000000000..fd8e4067a --- /dev/null +++ b/deploy/ai-studio/docker-compose.override.yml @@ -0,0 +1,61 @@ +# The bundled dev-grade Temporal cluster, plus the start-order edges that make the +# apps wait for it. Compose merges this over docker-compose.yml automatically, so +# a plain `docker compose up` runs everything locally. To use an operated cluster +# or Temporal Cloud instead, set COMPOSE_FILE=docker-compose.yml in .env: this +# file is then skipped, nothing here starts, and the apps depend only on app-db. + +services: + temporal-db: + image: postgres:16 + environment: + POSTGRES_DB: temporal + POSTGRES_USER: temporal + POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD:-temporal} + volumes: + - temporal-db-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'temporal', '-d', 'temporal'] + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped + + # auto-setup is dev-grade; sustained load should move to Temporal Cloud or an + # operated cluster — the apps only consume TEMPORAL_ADDRESS + temporal: + image: temporalio/auto-setup:1.29.6.1 + depends_on: + temporal-db: + condition: service_healthy + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_USER: temporal + POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:-temporal} + POSTGRES_SEEDS: temporal-db + restart: unless-stopped + + # Inspects this bundled cluster only. An external cluster comes with its own UI. + temporal-ui: + image: temporalio/ui:2.51.0 + profiles: [debug] + depends_on: + - temporal + environment: + TEMPORAL_ADDRESS: temporal:7233 + ports: + - '127.0.0.1:8233:8080' + restart: unless-stopped + + backend: + depends_on: + temporal: + condition: service_started + + worker: + depends_on: + temporal: + condition: service_started + +volumes: + temporal-db-data: diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 930ab9c32..9c0ddaa5a 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -1,6 +1,11 @@ # AI Studio production stack (WB-229). Usage: cp .env.example .env, set # AI_API_KEY, then `docker compose up -d --build`. Only `web` # publishes a port. +# +# This file has no Temporal cluster of its own: the apps connect to whatever +# TEMPORAL_ADDRESS names. The bundled dev-grade cluster lives in +# docker-compose.override.yml, which compose applies on top of this file by +# default; COMPOSE_FILE=docker-compose.yml in .env leaves it out. name: ai-studio @@ -10,8 +15,7 @@ x-runtime-build: &runtime-build target: runtime # Shared by backend and worker via YAML merge, so the two can never drift apart: -# the namespace must match or the worker polls a queue nobody submits to. Point -# these at an operated cluster or Temporal Cloud to retire the bundled one. +# the namespace must match or the worker polls a queue nobody submits to. x-temporal-env: &temporal-env TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233} TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default} @@ -43,47 +47,6 @@ services: retries: 12 restart: unless-stopped - temporal-db: - image: postgres:16 - environment: - POSTGRES_DB: temporal - POSTGRES_USER: temporal - POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD:-temporal} - volumes: - - temporal-db-data:/var/lib/postgresql/data - healthcheck: - test: ['CMD', 'pg_isready', '-U', 'temporal', '-d', 'temporal'] - interval: 5s - timeout: 3s - retries: 12 - restart: unless-stopped - - # auto-setup is dev-grade; sustained load should move to Temporal Cloud - # or an operated cluster — the apps only consume TEMPORAL_ADDRESS - temporal: - image: temporalio/auto-setup:1.29.6.1 - depends_on: - temporal-db: - condition: service_healthy - environment: - DB: postgres12 - DB_PORT: 5432 - POSTGRES_USER: temporal - POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:-temporal} - POSTGRES_SEEDS: temporal-db - restart: unless-stopped - - temporal-ui: - image: temporalio/ui:2.51.0 - profiles: [debug] - depends_on: - - temporal - environment: - TEMPORAL_ADDRESS: temporal:7233 - ports: - - '127.0.0.1:8233:8080' - restart: unless-stopped - # applies migrations at boot; on failure exits and `restart` retries backend: image: ai-studio-runtime @@ -108,8 +71,6 @@ services: depends_on: app-db: condition: service_healthy - temporal: - condition: service_started healthcheck: test: [ @@ -145,8 +106,6 @@ services: # backend healthy = migrations applied backend: condition: service_healthy - temporal: - condition: service_started restart: unless-stopped web: @@ -166,4 +125,3 @@ services: volumes: app-db-data: - temporal-db-data: From 1d5c39be60856278051dcbc742276602d58255bb Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 13:25:39 +0200 Subject: [PATCH 11/27] test(config): isolate env tests from the runner's environment loadEnv only stubbed the values a case supplied, so variables inherited from the shell leaked into the fresh module and cases asserting "unset" tested whatever the runner happened to carry. Every variable env.ts reads is now unset before each import, derived from the module's own keys so a new one cannot be missed, and restored afterwards. --- apps/backend/src/env.test.ts | 28 ++++++++++++++++++++++--- apps/execution-worker/src/env.test.ts | 30 +++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/env.test.ts b/apps/backend/src/env.test.ts index 5f151ec4c..a8d8131de 100644 --- a/apps/backend/src/env.test.ts +++ b/apps/backend/src/env.test.ts @@ -1,8 +1,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -// env.ts reads process.env once at module load, so every case needs a fresh module. +import { env as shape } from './env'; + +// The keys of `env` are the variable names, so a variable added to env.ts is +// cleared here without anyone remembering to list it. +const ENV_NAMES = Object.keys(shape); + +// env.ts reads process.env once at module load, so every case needs a fresh module +// and a clean environment: whatever the runner's shell carries is unset first. async function loadEnv(values: Record) { vi.resetModules(); + for (const name of ENV_NAMES) { + // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable + // eslint-disable-next-line unicorn/no-useless-undefined + vi.stubEnv(name, undefined); + } for (const [name, value] of Object.entries(values)) { vi.stubEnv(name, value); } @@ -14,6 +26,16 @@ afterEach(() => { vi.unstubAllEnvs(); }); +describe('loadEnv', () => { + it('ignores variables inherited from the runner', async () => { + vi.stubEnv('AI_BASE_URL', 'http://ambient.example/v1'); + + const env = await loadEnv({}); + + expect(env.AI_BASE_URL).toBeNull(); + }); +}); + describe('AI_API_KEY', () => { it('reads AI_API_KEY', async () => { const env = await loadEnv({ AI_API_KEY: 'key' }); @@ -31,7 +53,7 @@ describe('AI_API_KEY', () => { // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are // no external deployments to keep working. Rename the variable in .env instead. it('does not read the retired OPENROUTER_API_KEY name', async () => { - const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' }); + const env = await loadEnv({ OPENROUTER_API_KEY: 'old-key' }); expect(env.AI_API_KEY).toBeNull(); }); @@ -40,7 +62,7 @@ describe('AI_API_KEY', () => { describe('AI_BASE_URL and AI_MODEL', () => { // No built-in endpoint or model: the OpenRouter values live in .env.example only. it('are null when unset', async () => { - const env = await loadEnv({ AI_BASE_URL: '', AI_MODEL: '' }); + const env = await loadEnv({}); expect(env.AI_BASE_URL).toBeNull(); expect(env.AI_MODEL).toBeNull(); diff --git a/apps/execution-worker/src/env.test.ts b/apps/execution-worker/src/env.test.ts index 25ebcc4f7..78005afd3 100644 --- a/apps/execution-worker/src/env.test.ts +++ b/apps/execution-worker/src/env.test.ts @@ -1,8 +1,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -// env.ts reads process.env once at module load, so every case needs a fresh module. +import { env as shape } from './env'; + +// The keys of `env` are the variable names, so a variable added to env.ts is +// cleared here without anyone remembering to list it. +const ENV_NAMES = Object.keys(shape); + +// env.ts reads process.env once at module load, so every case needs a fresh module +// and a clean environment: whatever the runner's shell carries is unset first. async function loadEnv(values: Record) { vi.resetModules(); + for (const name of ENV_NAMES) { + // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable + // eslint-disable-next-line unicorn/no-useless-undefined + vi.stubEnv(name, undefined); + } for (const [name, value] of Object.entries(values)) { vi.stubEnv(name, value); } @@ -14,11 +26,21 @@ afterEach(() => { vi.unstubAllEnvs(); }); +describe('loadEnv', () => { + it('ignores variables inherited from the runner', async () => { + vi.stubEnv('AI_BASE_URL', 'http://ambient.example/v1'); + + const env = await loadEnv({}); + + expect(env.AI_BASE_URL).toBeNull(); + }); +}); + describe('AI_API_KEY', () => { // The worker used to throw at module load without a key. Booting keyless is the // point: a deployment that runs no AI nodes should not need an LLM account. it('is null when unset, rather than refusing to load', async () => { - const env = await loadEnv({ AI_API_KEY: '' }); + const env = await loadEnv({}); expect(env.AI_API_KEY).toBeNull(); }); @@ -33,7 +55,7 @@ describe('AI_API_KEY', () => { // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are // no external deployments to keep working. Rename the variable in .env instead. it('does not read the retired OPENROUTER_API_KEY name', async () => { - const env = await loadEnv({ AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' }); + const env = await loadEnv({ OPENROUTER_API_KEY: 'old-key' }); expect(env.AI_API_KEY).toBeNull(); }); @@ -42,7 +64,7 @@ describe('AI_API_KEY', () => { describe('AI_BASE_URL and AI_MODEL', () => { // No built-in endpoint or model: the OpenRouter values live in .env.example only. it('are null when unset', async () => { - const env = await loadEnv({ AI_BASE_URL: '', AI_MODEL: '' }); + const env = await loadEnv({}); expect(env.AI_BASE_URL).toBeNull(); expect(env.AI_MODEL).toBeNull(); From 168c5d94893ddae2f6329ac207a4fa0a0ef29fcb Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 13:50:02 +0200 Subject: [PATCH 12/27] docs(site): document secured and external Temporal configuration The standalone quick start covered AI_BASE_URL and keyless startup but none of the Temporal connection variables. Adds the namespace, TLS, API-key and mTLS table with the same semantics as the backend README, plus Temporal Cloud and private-CA examples. Also corrects the LLM section, which still described a built-in OpenRouter default, and moves the env snippets to the dotenv grammar the highlighter actually has. --- .../quick-start/standalone-app.mdx | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx index 3ff045379..f5d34e72c 100644 --- a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx +++ b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx @@ -160,24 +160,62 @@ To stop: `Ctrl+C`, then `pnpm infra:down`. ### Connect a real LLM (optional) -The stack starts without an LLM key: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. To make AI nodes work, add to both `apps/backend/.env` and `apps/execution-worker/.env`: +The stack starts without an LLM: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. AI nodes need three variables in both `apps/backend/.env` and `apps/execution-worker/.env`. The files `pnpm setup:env` created already carry an endpoint and a model for [OpenRouter](https://openrouter.ai), so only the key is missing: -```env +```dotenv AI_API_KEY=sk-or-v1-... +AI_BASE_URL=https://openrouter.ai/api/v1 AI_MODEL=mistralai/mistral-small-3.2-24b-instruct ``` -That default targets [OpenRouter](https://openrouter.ai). Any OpenAI-compatible endpoint works — set `AI_BASE_URL` (default `https://openrouter.ai/api/v1`) to a gateway or to a model hosted inside your own network, and nothing leaves it. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. +None of the three has a built-in default — nothing in the code points outside your network. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and no LLM traffic leaves it. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. + +### Connect a secured or external Temporal (optional) + +`pnpm infra:up` runs a plaintext dev cluster on `localhost:7233`. The connection is entirely env-driven, so an operated cluster or Temporal Cloud needs no code change. Set the same values in both `apps/backend/.env` and `apps/execution-worker/.env` — the two must agree on the namespace, or the worker polls a queue nobody submits to. + +| Variable | Purpose | Default | +| ------------------------ | -------------------------------------------------------------- | ---------------- | +| `TEMPORAL_ADDRESS` | `host:port` of the cluster | `127.0.0.1:7233` | +| `TEMPORAL_NAMESPACE` | Namespace to use | `default` | +| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty | +| `TEMPORAL_API_KEY` | API-key authentication (Temporal Cloud). Implies TLS | — | +| `TEMPORAL_TLS_CA_PATH` | PEM of a private certificate authority | — | +| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set together with the key | — | +| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set together with the certificate | — | + +Any credential turns TLS on by itself, so `TEMPORAL_TLS` is only needed to force TLS without credentials or to assert plaintext. Contradictions — half an mTLS pair, an API key together with a client certificate, or credentials alongside `TEMPORAL_TLS=false` — are rejected with an explanatory error when the connection opens. + +Temporal Cloud: + +```dotenv +TEMPORAL_ADDRESS=..tmprl.cloud:7233 +TEMPORAL_NAMESPACE=. +TEMPORAL_API_KEY= +``` + +A self-hosted cluster behind mTLS with a private CA: + +```dotenv +TEMPORAL_ADDRESS=temporal.internal:7233 +TEMPORAL_NAMESPACE=workflows +TEMPORAL_TLS_CA_PATH=/etc/workflowbuilder/tls/ca.pem +TEMPORAL_TLS_CERT_PATH=/etc/workflowbuilder/tls/client.pem +TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem +``` + +The Docker Compose deployment under `deploy/ai-studio/` reads the same variables and additionally lets you retire its bundled cluster; see its README for the `COMPOSE_FILE` switch and the `tls/` mount for certificate files. ## Troubleshooting -| Symptom | Cause | Fix | -| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. | -| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | -| AI Agent node fails with `ai_not_configured` | No LLM key — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY` in `apps/execution-worker/.env`. | -| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. | -| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. | +| Symptom | Cause | Fix | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. | +| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | +| AI Agent node fails with `ai_not_configured` | LLM not configured — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` in `apps/execution-worker/.env`. | +| Backend or worker exits with a `TEMPORAL_TLS` or `TEMPORAL_TLS_*_PATH` error | Contradictory Temporal settings (half an mTLS pair, API key plus client cert, credentials with `TEMPORAL_TLS=false`) | Remove one side, as the message says. Both `.env` files must carry the same values. | +| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. | +| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. | ## See also From 0173914e87699355995020c4f58a7747be380efa Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 14:29:00 +0200 Subject: [PATCH 13/27] fix(deploy): run compose from the project dir and ship both files to the VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow drove a VM-local docker-compose.yml with -f, which disables the automatic override and ignores COMPOSE_FILE — after the bundled cluster moved into docker-compose.override.yml the demo VM would have run without Temporal. The deploy step now copies both compose files from the repo on every run, executes compose from /app/ai-studio, and passes the pushed tags as RUNTIME_IMAGE / WEB_IMAGE, so one compose file serves local builds and the VM. --- .github/workflows/deploy-ai-studio.yml | 31 ++++++++++++++++++++------ deploy/ai-studio/.env.example | 7 ++++++ deploy/ai-studio/README.md | 7 ++++++ deploy/ai-studio/docker-compose.yml | 10 ++++++--- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml index 0a1850c10..5fce8c8a1 100644 --- a/.github/workflows/deploy-ai-studio.yml +++ b/.github/workflows/deploy-ai-studio.yml @@ -77,6 +77,9 @@ jobs: needs: build-and-push steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Log in to Azure uses: azure/login@v2 with: @@ -84,18 +87,32 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + # The VM runs the repo's compose files, shipped here on every deploy (base64, + # so the script stays free of quoting). Compose is run from the project + # directory, not with -f: that is what applies docker-compose.override.yml + # by default and honours COMPOSE_FILE from the VM's .env. - name: Refresh docker compose on Azure VM + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.APP }}:${{ needs.build-and-push.outputs.image_tag }} run: | + COMPOSE_B64=$(base64 -w0 deploy/ai-studio/docker-compose.yml) + OVERRIDE_B64=$(base64 -w0 deploy/ai-studio/docker-compose.override.yml) + SCRIPT=$(cat < docker-compose.yml + echo "$OVERRIDE_B64" | base64 -d > docker-compose.override.yml + export RUNTIME_IMAGE="$IMAGE-runtime" WEB_IMAGE="$IMAGE-web" + az acr login --name synergycodes + docker compose pull + docker compose up -d --no-build --force-recreate --remove-orphans + echo DEPLOY_SCRIPT_SUCCEEDED + EOF + ) OUTPUT=$(az vm run-command invoke \ --name ${{ vars.AI_STUDIO_VM_NAME }} \ --resource-group ${{ vars.AI_STUDIO_VM_RG }} \ --command-id RunShellScript \ - --scripts ' - set -e - az acr login --name synergycodes - docker compose -f /app/ai-studio/docker-compose.yml pull - docker compose -f /app/ai-studio/docker-compose.yml up -d --no-build --force-recreate - echo DEPLOY_SCRIPT_SUCCEEDED - ') + --scripts "$SCRIPT") echo "$OUTPUT" echo "$OUTPUT" | grep -q DEPLOY_SCRIPT_SUCCEEDED diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index ad4532384..54cc923d0 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -81,6 +81,13 @@ TEMPORAL_TLS_CA_PATH= TEMPORAL_TLS_CERT_PATH= TEMPORAL_TLS_KEY_PATH= +# --- images ------------------------------------------------------------------- + +# Prebuilt runtime and web images from a registry. Leave empty to build locally +# (`docker compose up --build`). The deploy workflow passes the tags it pushed. +RUNTIME_IMAGE= +WEB_IMAGE= + # --- databases (internal network only, not published) ------------------------- APP_DB_PASSWORD=wb diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index c922d2e60..e467451d8 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -119,6 +119,13 @@ docker compose down # stop (volumes survive) docker exec ai-studio-app-db-1 pg_dump -U wb workflow_builder > backup.sql ``` +The public demo is deployed by the `Deploy AI Studio` GitHub Actions workflow: +it builds and pushes both images to the registry, copies `docker-compose.yml` +and `docker-compose.override.yml` from the repo to the VM, and runs compose +there with `RUNTIME_IMAGE` / `WEB_IMAGE` pointing at the tags it just pushed. +The VM's compose files are that copy — change them in the repo, never on the +VM. Only `.env` lives on the VM alone. + Workflow data is treated as ephemeral for the public demo — losing the volumes is acceptable; there is nothing precious in them. diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 9c0ddaa5a..11cee6dcb 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -6,6 +6,10 @@ # TEMPORAL_ADDRESS names. The bundled dev-grade cluster lives in # docker-compose.override.yml, which compose applies on top of this file by # default; COMPOSE_FILE=docker-compose.yml in .env leaves it out. +# +# RUNTIME_IMAGE / WEB_IMAGE name prebuilt images from a registry; unset, the +# services build locally under the default names. The deploy workflow sets them +# to the tags it just pushed, so this file is the one the demo VM runs too. name: ai-studio @@ -49,7 +53,7 @@ services: # applies migrations at boot; on failure exits and `restart` retries backend: - image: ai-studio-runtime + image: ${RUNTIME_IMAGE:-ai-studio-runtime} build: *runtime-build command: ['pnpm', '--filter', 'backend', 'start:prod'] environment: @@ -87,7 +91,7 @@ services: # crash-loops until Temporal answers (no usable healthcheck); restart converges it worker: - image: ai-studio-runtime + image: ${RUNTIME_IMAGE:-ai-studio-runtime} build: *runtime-build command: ['pnpm', '--filter', 'execution-worker', 'start:prod'] environment: @@ -109,7 +113,7 @@ services: restart: unless-stopped web: - image: ai-studio-web + image: ${WEB_IMAGE:-ai-studio-web} build: context: ../.. dockerfile: deploy/ai-studio/Dockerfile From b88c929700f6fdd53a5ef8ed5ea418628dc4354b Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 14:37:21 +0200 Subject: [PATCH 14/27] fix(deploy): keep certificate files out of the image build context The build context is the repo root and .dockerignore excluded only .env files, so PEMs dropped into deploy/ai-studio/tls per the mTLS docs were copied into the runtime image by `COPY . .`. The directory is now excluded; the files reach the containers through the read-only mount only. --- .dockerignore | 3 +++ deploy/ai-studio/.env.example | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 8ec9bf851..b491314a1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,3 +31,6 @@ examples/ **/.env **/.env.* !**/.env.example + +# certificate material is mounted at runtime — never built into an image +deploy/ai-studio/tls diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 54cc923d0..9bd35e8c4 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -75,7 +75,8 @@ TEMPORAL_API_KEY= # TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem # # CA alone covers a private issuer without client auth. Cert and key go together, -# and a client certificate excludes TEMPORAL_API_KEY. +# and a client certificate excludes TEMPORAL_API_KEY. ./tls is excluded from the +# image build context; a directory outside the checkout keeps it out of git too. TEMPORAL_TLS_DIR=./tls TEMPORAL_TLS_CA_PATH= TEMPORAL_TLS_CERT_PATH= From e0123d91e41c01a53cfc016808268233eb7446cf Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 14:38:51 +0200 Subject: [PATCH 15/27] fix(config): ship .env.example with an empty AI_API_KEY The examples carried the placeholder `sk-or-...`, which envOptional treats as a configured key: a verbatim copy skipped the boot warning and sent requests to OpenRouter with a bogus token, surfacing a provider 401 instead of the documented ai_not_configured / 501 paths. The value is now empty and the key format lives in the comment. --- apps/backend/.env.example | 5 +++-- apps/execution-worker/.env.example | 3 ++- deploy/ai-studio/.env.example | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index cae5eeef5..ee04cfdbc 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -38,8 +38,9 @@ WB_AUTH_PORT=allow-all TURNSTILE_SECRET_KEY= # API key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt). # Optional: leave empty to disable AI adapt (the endpoint returns 501). The -# execution worker keeps its own key for running workflows. -AI_API_KEY=sk-or-... +# execution worker keeps its own key for running workflows. OpenRouter keys look +# like sk-or-v1-... +AI_API_KEY= # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own # network. Must be the base URL, without a trailing /chat/completions. OpenRouter by default. AI_BASE_URL=https://openrouter.ai/api/v1 diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index d5e221664..df0d12746 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -23,7 +23,8 @@ TEMPORAL_TLS_KEY_PATH= # LLM for AI Agent nodes. Optional: leave empty and the worker still starts and # runs every other node type — AI Agent nodes then fail with `ai_not_configured`. -AI_API_KEY=sk-or-... +# OpenRouter keys look like sk-or-v1-... +AI_API_KEY= # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own # network. Must be the base URL, without a trailing /chat/completions. OpenRouter by default. AI_BASE_URL=https://openrouter.ai/api/v1 diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 9bd35e8c4..59b723515 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -6,8 +6,9 @@ # --- LLM -------------------------------------------------------------------- # Server-side only; never reaches the browser. Pair it with a provider-side -# spend cap (hard $/day ceiling) — see README "Spend safety". -AI_API_KEY=sk-or-... +# spend cap (hard $/day ceiling) — see README "Spend safety". Empty keeps AI +# Agent nodes off; OpenRouter keys look like sk-or-v1-... +AI_API_KEY= # Any OpenAI-compatible endpoint. There is no built-in default: the value below # is the OpenRouter setup the stack used before the endpoint became configurable. From 9337b8b608b6d0b11097b6f01f8961d69789c26b Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 14:41:57 +0200 Subject: [PATCH 16/27] docs: align the root README with the no-default LLM configuration The Full Stack Demo section still described AI_BASE_URL as defaulting to OpenRouter and listed a two-variable setup, contradicting the code and the docs site. It now mirrors the docs page: three variables, pre-filled by setup:env, no built-in default. --- README.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6c349757b..99f449c9a 100644 --- a/README.md +++ b/README.md @@ -203,24 +203,25 @@ To stop: `Ctrl+C`, then `pnpm infra:down`. #### Connect a real LLM (optional) -The stack starts without an LLM key: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. To make AI nodes work, add to both `apps/backend/.env` and `apps/execution-worker/.env`: +The stack starts without an LLM: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. AI nodes need three variables in both `apps/backend/.env` and `apps/execution-worker/.env`. The files `pnpm setup:env` created already carry an endpoint and a model for [OpenRouter](https://openrouter.ai), so only the key is missing: ```env AI_API_KEY=sk-or-v1-... +AI_BASE_URL=https://openrouter.ai/api/v1 AI_MODEL=mistralai/mistral-small-3.2-24b-instruct ``` -That default targets [OpenRouter](https://openrouter.ai). Any OpenAI-compatible endpoint works — set `AI_BASE_URL` (default `https://openrouter.ai/api/v1`) to a gateway or to a model hosted inside your own network, and nothing leaves it. If the model id is wrong the first AI node fails at runtime and the error surfaces in the UI log panel. +None of the three has a built-in default — nothing in the code points outside your network. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and no LLM traffic leaves it. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. ### Troubleshooting -| Symptom | Cause | Fix | -| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port | -| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | -| AI Agent node fails with `ai_not_configured` | No LLM key — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY` in `apps/execution-worker/.env` | -| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily | -| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun | +| Symptom | Cause | Fix | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port | +| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | +| AI Agent node fails with `ai_not_configured` | LLM not configured — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` in `apps/execution-worker/.env` | +| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily | +| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun | For the full command reference, see the table in [`CLAUDE.md`](./CLAUDE.md) or the documentation site. From f86f26f9a6302b4b634a585b2b662f18538d12c2 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 14:44:37 +0200 Subject: [PATCH 17/27] fix(deploy): refuse to start while OPENROUTER_API_KEY is still set Compose no longer passes the retired variable, so a pre-rename .env came up with AI silently off and only a warn-level log to explain it. A compose-level guard now fails interpolation with a message naming the rename and the two new variables; the README and .env.example carry the upgrade note. --- deploy/ai-studio/.env.example | 2 ++ deploy/ai-studio/README.md | 6 ++++++ deploy/ai-studio/docker-compose.yml | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 59b723515..ad2f592a6 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -8,6 +8,8 @@ # Server-side only; never reaches the browser. Pair it with a provider-side # spend cap (hard $/day ceiling) — see README "Spend safety". Empty keeps AI # Agent nodes off; OpenRouter keys look like sk-or-v1-... +# Renamed from OPENROUTER_API_KEY: compose refuses to start while the old name is +# still set, so rename it here rather than adding this one next to it. AI_API_KEY= # Any OpenAI-compatible endpoint. There is no built-in default: the value below diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index e467451d8..3a64a6976 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -95,6 +95,12 @@ used before the endpoint became configurable. Leave any of them empty and the stack still comes up: every node type runs except AI Agent nodes, which fail with `ai_not_configured`. +**Upgrading from `OPENROUTER_API_KEY`.** The key is now `AI_API_KEY`, and the +endpoint and model are no longer built in. In `.env`, rename the key and add +`AI_BASE_URL` and `AI_MODEL` (the OpenRouter values are in `.env.example`). +Compose refuses to start while the old name is still set, so a stale `.env` +fails loudly instead of coming up with AI silently off. + **Pointing at a different Temporal.** Every `TEMPORAL_*` variable reaches the backend and the worker from one shared block in the compose file, so the two cannot disagree. `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_TLS` and diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 11cee6dcb..f9fe9e2d1 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -13,6 +13,11 @@ name: ai-studio +# Fails fast while a pre-rename .env is still in place. OPENROUTER_API_KEY was +# renamed to AI_API_KEY and is no longer read; without this, a stale .env would +# come up with AI silently off. The inner :? only fires when the outer :+ does. +x-retired-openrouter-key: ${OPENROUTER_API_KEY:+${OPENROUTER_API_KEY_RETIRED:?OPENROUTER_API_KEY was renamed to AI_API_KEY - rename it and set AI_BASE_URL and AI_MODEL too, see .env.example}} + x-runtime-build: &runtime-build context: ../.. dockerfile: deploy/ai-studio/Dockerfile From f3ef683d2d45b0ab2cb8b46e93899eebf25f0541 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 4 Sep 2026 14:52:46 +0200 Subject: [PATCH 18/27] docs: correct the Temporal failure mode and remove default-wording drift The troubleshooting row claimed the backend exits on a contradictory TEMPORAL_* setup; it connects on first use, so it boots, passes its healthcheck and fails on the first Play. Also aligns wording across the READMEs, .env.example files and docs page with the code: no built-in LLM default, any credential implies TLS, provider-neutral phrasing. --- apps/backend/.env.example | 3 ++- apps/backend/README.md | 18 +++++++-------- .../quick-start/standalone-app.mdx | 18 +++++++-------- apps/execution-worker/.env.example | 3 ++- apps/execution-worker/README.md | 2 +- deploy/ai-studio/.env.example | 4 ++-- deploy/ai-studio/README.md | 23 +++++++++++-------- 7 files changed, 38 insertions(+), 33 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index ee04cfdbc..1af756ffe 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -42,7 +42,8 @@ TURNSTILE_SECRET_KEY= # like sk-or-v1-... AI_API_KEY= # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own -# network. Must be the base URL, without a trailing /chat/completions. OpenRouter by default. +# network. Must be the base URL, without a trailing /chat/completions. +# Pre-filled with OpenRouter's URL; there is no built-in default. AI_BASE_URL=https://openrouter.ai/api/v1 # Model id as the endpoint above understands it. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/apps/backend/README.md b/apps/backend/README.md index 0cb30e12f..9f6a47100 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -7,7 +7,7 @@ > **Note:** setup is in [root README "Path C. Run the full stack demo"](../../README.md#path-c-run-the-full-stack-demo). This file documents the backend's internals, not how to start it. -Backend execution layer for Workflow Builder AI Studio plugin. Runs AI workflows defined on the canvas via Temporal + OpenRouter. +Backend execution layer for Workflow Builder AI Studio plugin. Runs AI workflows defined on the canvas via Temporal and an OpenAI-compatible LLM endpoint (`AI_BASE_URL`). ## Architecture @@ -65,14 +65,14 @@ runs everything except AI Agent nodes. See [`apps/execution-worker/README.md`](. The defaults above open a plaintext connection to the bundled dev cluster. Everything about the connection is env-driven, so a hardened cluster or Temporal Cloud needs no code change: -| Var | Purpose | Default | -| ------------------------ | ------------------------------------------------------------ | --------- | -| `TEMPORAL_NAMESPACE` | Namespace to use. Must match the worker's | `default` | -| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | — | -| `TEMPORAL_API_KEY` | API key auth (Temporal Cloud). Implies TLS | — | -| `TEMPORAL_TLS_CA_PATH` | PEM for a private certificate authority | — | -| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set with the key | — | -| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set with the certificate | — | +| Var | Purpose | Default | +| ------------------------ | ------------------------------------------------------------ | ------------- | +| `TEMPORAL_NAMESPACE` | Namespace to use. Must match the worker's | `default` | +| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty (infer) | +| `TEMPORAL_API_KEY` | API key auth (Temporal Cloud). Implies TLS | — | +| `TEMPORAL_TLS_CA_PATH` | PEM for a private certificate authority | — | +| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set with the key | — | +| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set with the certificate | — | Any credential turns TLS on by itself, so `TEMPORAL_TLS` only has to be set to force TLS with no credentials, or to assert plaintext. Contradictory combinations — half an mTLS pair, an API key diff --git a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx index f5d34e72c..cbb43e5d9 100644 --- a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx +++ b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx @@ -178,7 +178,7 @@ None of the three has a built-in default — nothing in the code points outside | ------------------------ | -------------------------------------------------------------- | ---------------- | | `TEMPORAL_ADDRESS` | `host:port` of the cluster | `127.0.0.1:7233` | | `TEMPORAL_NAMESPACE` | Namespace to use | `default` | -| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty | +| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty (infer) | | `TEMPORAL_API_KEY` | API-key authentication (Temporal Cloud). Implies TLS | — | | `TEMPORAL_TLS_CA_PATH` | PEM of a private certificate authority | — | | `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set together with the key | — | @@ -208,14 +208,14 @@ The Docker Compose deployment under `deploy/ai-studio/` reads the same variables ## Troubleshooting -| Symptom | Cause | Fix | -| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. | -| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | -| AI Agent node fails with `ai_not_configured` | LLM not configured — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` in `apps/execution-worker/.env`. | -| Backend or worker exits with a `TEMPORAL_TLS` or `TEMPORAL_TLS_*_PATH` error | Contradictory Temporal settings (half an mTLS pair, API key plus client cert, credentials with `TEMPORAL_TLS=false`) | Remove one side, as the message says. Both `.env` files must carry the same values. | -| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. | -| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. | +| Symptom | Cause | Fix | +| ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. | +| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | +| AI Agent node fails with `ai_not_configured` | LLM not configured — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` in `apps/execution-worker/.env`. | +| Worker exits at boot with a `TEMPORAL_TLS` or `TEMPORAL_TLS_*_PATH` error, or the backend returns 500 on the first Play | Contradictory Temporal settings (half an mTLS pair, API key plus client cert, credentials with `TEMPORAL_TLS=false`). The backend connects on first use, so it boots and passes its healthcheck regardless | Remove one side, as the message says; the backend logs it on that first Play. Both `.env` files must carry the same values. | +| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. | +| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. | ## See also diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index df0d12746..0b5e1d73a 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -26,7 +26,8 @@ TEMPORAL_TLS_KEY_PATH= # OpenRouter keys look like sk-or-v1-... AI_API_KEY= # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own -# network. Must be the base URL, without a trailing /chat/completions. OpenRouter by default. +# network. Must be the base URL, without a trailing /chat/completions. +# Pre-filled with OpenRouter's URL; there is no built-in default. AI_BASE_URL=https://openrouter.ai/api/v1 # Model id as the endpoint above understands it. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index 78059ff0c..ed99a83cd 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -26,7 +26,7 @@ Requires Postgres + Temporal running. Start them with `pnpm infra:up`. ## Environment -See `.env.example`. Every variable has a working default: +See `.env.example`. Everything the bundled dev stack needs has a working default; the `AI_*` trio is optional: | Var | Purpose | Default | | -------------------- | ------------------------------------- | ---------------------------------------------------- | diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index ad2f592a6..6ba017acb 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -15,7 +15,7 @@ AI_API_KEY= # Any OpenAI-compatible endpoint. There is no built-in default: the value below # is the OpenRouter setup the stack used before the endpoint became configurable. # Point it at a gateway or a model inside your own network and no LLM traffic -# leaves it. OpenRouter by default. +# leaves it. Pre-filled with OpenRouter's URL; there is no built-in default. AI_BASE_URL=https://openrouter.ai/api/v1 # Model id as the endpoint above understands it. This one @@ -62,7 +62,7 @@ VITE_BACKEND_URL= # Run `docker compose down --remove-orphans` once when switching, so the retired # temporal containers from the bundled setup are removed. # -# TEMPORAL_TLS: empty infers (an API key turns TLS on by itself), `true` requires +# TEMPORAL_TLS: empty infers (any credential turns TLS on by itself), `true` requires # TLS with the OS trust store, `false` asserts plaintext. TEMPORAL_ADDRESS=temporal:7233 TEMPORAL_NAMESPACE=default diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index 3a64a6976..6bef9f9e5 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -5,15 +5,15 @@ any Docker host — an Azure VM, AWS, on-prem — with no cloud-specific glue. ## What runs -| Service | Image | Role | Exposed | -| ------------- | ------------------------------ | ----------------------------------------------- | ------------------------ | -| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | -| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal | -| `worker` | `ai-studio-runtime` | Temporal worker, makes the OpenRouter LLM calls | internal | -| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | -| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | -| `temporal-db` | `postgres:16` | Temporal's own state store | internal | -| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | +| Service | Image | Role | Exposed | +| ------------- | ------------------------------ | --------------------------------------------- | ------------------------ | +| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | +| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal | +| `worker` | `ai-studio-runtime` | Temporal worker, makes the LLM calls | internal | +| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | +| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | +| `temporal-db` | `postgres:16` | Temporal's own state store | internal | +| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | The three Temporal rows come from [`docker-compose.override.yml`](docker-compose.override.yml), which compose @@ -108,7 +108,10 @@ cannot disagree. `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_TLS` and `COMPOSE_FILE=docker-compose.yml` to `.env` at the same time: it leaves the override file out, so the bundled cluster is not started and cannot block the apps, and `backend` / `worker` depend only on `app-db`. Run -`docker compose down --remove-orphans` once when switching. The bundled debug +`docker compose down --remove-orphans` once when switching. A contradictory +`TEMPORAL_*` combination stops the worker at boot (`docker compose logs worker`); +the backend connects on first use, so it still passes its healthcheck and fails +on the first Play — check the worker, not `/api/health`. The bundled debug UI (`--profile debug`) is part of the override and only ever shows the bundled cluster — an external cluster has its own UI. For a private CA or mTLS, drop the PEM files into [`tls/`](tls/) (git-ignored, mounted read-only into both containers at `/etc/workflowbuilder/tls`) and set From 984097f483fb22cc617354a6a68cac392daf441f Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 12:27:45 +0200 Subject: [PATCH 19/27] fix(deploy): keep custom TEMPORAL_TLS_DIR out of the image build context Only deploy/ai-studio/tls was dockerignored, so TEMPORAL_TLS_DIR=./certs with a key inside the checkout was copied into the runtime image by COPY . . on a local build. Exclude deploy/ as a whole (re-including only deploy/ai-studio/nginx, the one file the Dockerfile copies) and *.pem/*.key/*.crt/*.cer/*.p12/ *.pfx repo-wide. Document that TEMPORAL_TLS_DIR supports exactly ./tls or a directory outside the checkout. Verified with control files: none of the in-repo locations reach the build context; nginx/default.conf and a repo-root positive control do. --- .dockerignore | 13 +++++++++++-- deploy/ai-studio/.env.example | 17 ++++++++++++----- deploy/ai-studio/README.md | 5 ++++- deploy/ai-studio/docker-compose.yml | 3 ++- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.dockerignore b/.dockerignore index b491314a1..19d349853 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,5 +32,14 @@ examples/ **/.env.* !**/.env.example -# certificate material is mounted at runtime — never built into an image -deploy/ai-studio/tls +# certificate material is mounted at runtime — never built into an image. The +# Dockerfile needs one file from deploy/, so the rest stays out of the context: +# a TEMPORAL_TLS_DIR under deploy/ cannot reach COPY . . whatever it is named. +deploy/ +!deploy/ai-studio/nginx +**/*.pem +**/*.key +**/*.crt +**/*.cer +**/*.p12 +**/*.pfx diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 6ba017acb..31357a2f6 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -69,17 +69,24 @@ TEMPORAL_NAMESPACE=default TEMPORAL_TLS= TEMPORAL_API_KEY= -# Private CA or mTLS. Drop the PEM files into ./tls (or point TEMPORAL_TLS_DIR at -# another directory); both containers see it read-only at /etc/workflowbuilder/tls, -# so the three paths below are container paths: +# Private CA or mTLS. Drop the PEM files into ./tls, or point TEMPORAL_TLS_DIR at a +# directory OUTSIDE the checkout (an absolute path such as /etc/wb-tls). Both +# containers see it read-only at /etc/workflowbuilder/tls, so the three paths below +# are container paths: # # TEMPORAL_TLS_CA_PATH=/etc/workflowbuilder/tls/ca.pem # TEMPORAL_TLS_CERT_PATH=/etc/workflowbuilder/tls/client.pem # TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem # # CA alone covers a private issuer without client auth. Cert and key go together, -# and a client certificate excludes TEMPORAL_API_KEY. ./tls is excluded from the -# image build context; a directory outside the checkout keeps it out of git too. +# and a client certificate excludes TEMPORAL_API_KEY. +# +# Never use any other directory inside the repository. A local `docker compose +# up --build` sends the whole checkout as the build context, and the Dockerfile +# copies it into the runtime image; the read-only mount does not remove that +# second copy. Only ./tls (and deploy/ as a whole, plus *.pem/*.key/*.crt/*.p12/ +# *.pfx anywhere) is excluded by .dockerignore, so a key parked elsewhere in the +# checkout ships to everyone who can pull the image. TEMPORAL_TLS_DIR=./tls TEMPORAL_TLS_CA_PATH= TEMPORAL_TLS_CERT_PATH= diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index 6bef9f9e5..f7aacc180 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -116,7 +116,10 @@ UI (`--profile debug`) is part of the override and only ever shows the bundled cluster — an external cluster has its own UI. For a private CA or mTLS, drop the PEM files into [`tls/`](tls/) (git-ignored, mounted read-only into both containers at `/etc/workflowbuilder/tls`) and set `TEMPORAL_TLS_CA_PATH` / `_CERT_PATH` / `_KEY_PATH` to those container paths — -see [.env.example](.env.example) for the exact lines. +see [.env.example](.env.example) for the exact lines. `TEMPORAL_TLS_DIR` may +point at `./tls` or at a directory outside the checkout, nothing else: the +whole repository is the image build context, so a key placed in any other +in-repo directory is copied into the runtime image by a local build. ## Operations diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index f9fe9e2d1..16064e3ca 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -36,7 +36,8 @@ x-temporal-env: &temporal-env TEMPORAL_TLS_KEY_PATH: ${TEMPORAL_TLS_KEY_PATH:-} # PEM files for a private CA or mTLS. ./tls ships empty (and git-ignored) so the -# mount always resolves; plaintext deployments never touch it. +# mount always resolves; plaintext deployments never touch it. TEMPORAL_TLS_DIR +# must stay ./tls or leave the checkout — anything else in-repo is build context. x-temporal-tls-volumes: &temporal-tls-volumes - ${TEMPORAL_TLS_DIR:-./tls}:/etc/workflowbuilder/tls:ro From e434d87bace79da1510598e19463a4294a4f2bab Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 12:37:05 +0200 Subject: [PATCH 20/27] fix(ci): persist deployed image tags in the VM's .env The deploy step only exported RUNTIME_IMAGE / WEB_IMAGE for its own shell, so any later compose command on the VM (worker restart after a model change, the debug profile) fell back to the local ai-studio-* build names. Rewrite the two image lines in /app/ai-studio/.env on every deploy instead, leaving the rest of the file untouched, and drop the export so the deploy itself runs off the persisted values. Verified locally: after a simulated deploy against a stale .env, a fresh process with no inherited variables resolves both deployed tags via `docker compose config --images`; other .env lines and mode are unchanged. --- .github/workflows/deploy-ai-studio.yml | 9 ++++++++- deploy/ai-studio/.env.example | 3 ++- deploy/ai-studio/README.md | 11 +++++++---- deploy/ai-studio/docker-compose.yml | 4 ++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml index 5fce8c8a1..8c203cd73 100644 --- a/.github/workflows/deploy-ai-studio.yml +++ b/.github/workflows/deploy-ai-studio.yml @@ -91,6 +91,11 @@ jobs: # so the script stays free of quoting). Compose is run from the project # directory, not with -f: that is what applies docker-compose.override.yml # by default and honours COMPOSE_FILE from the VM's .env. + # + # The image tags are written into that .env rather than exported: an export + # dies with this shell, and the next `docker compose up -d worker` on the VM + # would fall back to the local ai-studio-* names. Only the two image lines + # are replaced; the rest of .env is the VM's own and stays untouched. - name: Refresh docker compose on Azure VM env: IMAGE: ${{ env.REGISTRY }}/${{ env.APP }}:${{ needs.build-and-push.outputs.image_tag }} @@ -102,7 +107,9 @@ jobs: cd /app/ai-studio echo "$COMPOSE_B64" | base64 -d > docker-compose.yml echo "$OVERRIDE_B64" | base64 -d > docker-compose.override.yml - export RUNTIME_IMAGE="$IMAGE-runtime" WEB_IMAGE="$IMAGE-web" + touch .env + { grep -vE '^(RUNTIME_IMAGE|WEB_IMAGE)=' .env || true; printf 'RUNTIME_IMAGE=%s\nWEB_IMAGE=%s\n' "$IMAGE-runtime" "$IMAGE-web"; } > .env.tmp + chmod --reference=.env .env.tmp && mv .env.tmp .env az acr login --name synergycodes docker compose pull docker compose up -d --no-build --force-recreate --remove-orphans diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 31357a2f6..c1602f9ab 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -95,7 +95,8 @@ TEMPORAL_TLS_KEY_PATH= # --- images ------------------------------------------------------------------- # Prebuilt runtime and web images from a registry. Leave empty to build locally -# (`docker compose up --build`). The deploy workflow passes the tags it pushed. +# (`docker compose up --build`). On the deploy VM the workflow rewrites these two +# lines with the exact tags it pushed, so later compose commands keep using them. RUNTIME_IMAGE= WEB_IMAGE= diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index f7aacc180..d2590315d 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -133,10 +133,13 @@ docker exec ai-studio-app-db-1 pg_dump -U wb workflow_builder > backup.sql The public demo is deployed by the `Deploy AI Studio` GitHub Actions workflow: it builds and pushes both images to the registry, copies `docker-compose.yml` -and `docker-compose.override.yml` from the repo to the VM, and runs compose -there with `RUNTIME_IMAGE` / `WEB_IMAGE` pointing at the tags it just pushed. -The VM's compose files are that copy — change them in the repo, never on the -VM. Only `.env` lives on the VM alone. +and `docker-compose.override.yml` from the repo to the VM, writes the tags it +just pushed into the VM's `.env` as `RUNTIME_IMAGE` / `WEB_IMAGE`, and runs +compose there. Because the tags live in `.env`, every later compose command on +the VM (`docker compose up -d worker` after a model change, `--profile debug`) +resolves the deployed images, not the local `ai-studio-*` build names. The VM's +compose files are that copy — change them in the repo, never on the VM. Only +`.env` lives on the VM alone; the deploy replaces just its two image lines. Workflow data is treated as ephemeral for the public demo — losing the volumes is acceptable; there is nothing precious in them. diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 16064e3ca..48637cac4 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -8,8 +8,8 @@ # default; COMPOSE_FILE=docker-compose.yml in .env leaves it out. # # RUNTIME_IMAGE / WEB_IMAGE name prebuilt images from a registry; unset, the -# services build locally under the default names. The deploy workflow sets them -# to the tags it just pushed, so this file is the one the demo VM runs too. +# services build locally under the default names. The deploy workflow writes the +# tags it just pushed into the VM's .env, so this file is the one the demo VM runs too. name: ai-studio From db829b7be812c450ea4b44ef29c2d6d13cc19fd8 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 13:58:53 +0200 Subject: [PATCH 21/27] test(backend,execution-worker): prove TLS, mTLS and API-key transport end to end The connection builder tests only compared the options object built from fake certificate bytes; nothing showed the backend's gRPC client or the worker's native transport would complete or refuse a real handshake. Add a shared test harness in apps/tools (throwaway CA with server and client leaves, a TLS-terminating proxy in front of the Temporal dev server, and an HTTP/2 endpoint that records bearer tokens) and drive both builders through it: private CA, mutual TLS, an untrusted server CA, a client certificate from the wrong CA, an API key inside the TLS session, and work in a non-default namespace. No Docker or Temporal Cloud needed. --- .github/workflows/pr-check.yml | 9 +- apps/backend/package.json | 2 + .../engine/temporal-connection.tls.test.ts | 149 ++++++++++++++++++ apps/execution-worker/package.json | 3 + .../temporal/temporal-connection.tls.test.ts | 143 +++++++++++++++++ .../test-fixtures/tls-probe-workflow.ts | 5 + apps/tools/README.md | 4 + apps/tools/package.json | 7 + .../tls-test-harness/authorization-sink.ts | 47 ++++++ .../src/tls-test-harness/certificates.ts | 77 +++++++++ apps/tools/src/tls-test-harness/index.ts | 6 + apps/tools/src/tls-test-harness/tls-proxy.ts | 73 +++++++++ knip.config.js | 10 +- pnpm-lock.yaml | 123 +++++++++++++++ 14 files changed, 652 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/engine/temporal-connection.tls.test.ts create mode 100644 apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts create mode 100644 apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts create mode 100644 apps/tools/src/tls-test-harness/authorization-sink.ts create mode 100644 apps/tools/src/tls-test-harness/certificates.ts create mode 100644 apps/tools/src/tls-test-harness/index.ts create mode 100644 apps/tools/src/tls-test-harness/tls-proxy.ts diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9e01a1c89..d034a1f43 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -219,10 +219,11 @@ jobs: execution: name: Execution pipeline lint + typecheck + test runs-on: ubuntu-latest - # No `services:` block: all three suites are pure unit tests against - # in-memory fakes — no Postgres, no Temporal, no API keys. If a suite here - # ever needs real infra, give it its own job rather than adding services - # to this one. + # No `services:` block: the suites run against in-memory fakes — no Postgres, + # no API keys. The one exception is the TLS connection tests in backend and + # worker, which start Temporal's dev server themselves (@temporalio/testing + # downloads the CLI on first run). If a suite here ever needs infra it cannot + # start itself, give it its own job rather than adding services to this one. steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/apps/backend/package.json b/apps/backend/package.json index 339d9be78..2b6bf7eed 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -33,7 +33,9 @@ "zod": "^4.3.6" }, "devDependencies": { + "@temporalio/testing": "catalog:", "@types/node": "^22.12.0", + "@workflow-builder/tools": "workspace:*", "drizzle-kit": "^0.31.0", "vitest": "^3.0.4" } diff --git a/apps/backend/src/engine/temporal-connection.tls.test.ts b/apps/backend/src/engine/temporal-connection.tls.test.ts new file mode 100644 index 000000000..6e8f46f55 --- /dev/null +++ b/apps/backend/src/engine/temporal-connection.tls.test.ts @@ -0,0 +1,149 @@ +// Drives the options this builder produces through a real TLS handshake: a Temporal +// dev server behind a TLS-terminating proxy, with certificates minted for the run. +// The unit tests next door prove the shape of the options; this file proves they connect. +import { Client, Connection } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + type TestPki, + type TestPkiFiles, + createTestPki, + startAuthorizationSink, + startTlsProxy, + writeTestPki, +} from '@workflow-builder/tools/tls-test-harness'; + +import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; + +const NAMESPACE = 'tls-test'; +const CONNECT_TIMEOUT = '3s'; + +const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; + +function config(overrides: Partial): TemporalConnectionConfig { + return { ...empty, ...overrides }; +} + +function connectVia(address: string, overrides: Partial) { + return Connection.connect({ + address, + connectTimeout: CONNECT_TIMEOUT, + ...buildTemporalConnectionOptions(config(overrides)), + }); +} + +type Pki = { pki: TestPki; files: TestPkiFiles }; + +function mint(name: string): Pki { + const pki = createTestPki(name); + return { pki, files: writeTestPki(pki, name) }; +} + +describe('Temporal client over TLS', () => { + let env: TestWorkflowEnvironment; + // `trusted` is what the server presents and requires; `stranger` is a second, unrelated CA. + let trusted: Pki; + let stranger: Pki; + + beforeAll(async () => { + [env, trusted, stranger] = await Promise.all([ + TestWorkflowEnvironment.createLocal({ server: { extraArgs: ['--namespace', NAMESPACE] } }), + mint('trusted'), + mint('stranger'), + ]); + }, 300_000); + + afterAll(async () => { + await env?.teardown(); + }); + + it('connects through a private CA and starts a workflow in a non-default namespace', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const connection = await connectVia(proxy.address, { caPath: trusted.files.ca }); + const client = new Client({ connection, namespace: NAMESPACE }); + const handle = await client.workflow.start('tls-probe', { + taskQueue: 'tls-probe', + workflowId: `tls-probe-${Date.now()}`, + }); + + // Read back over the dev server's own plaintext connection, so the assertion + // does not depend on the connection under test. + const inNamespace = new Client({ connection: env.connection, namespace: NAMESPACE }); + await expect(inNamespace.workflow.getHandle(handle.workflowId).describe()).resolves.toMatchObject({ + status: { name: 'RUNNING' }, + }); + await expect(env.client.workflow.getHandle(handle.workflowId).describe()).rejects.toThrow(); + + await handle.terminate('tls test done'); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('authenticates with a client certificate when the server requires one', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + const connection = await connectVia(proxy.address, { + caPath: trusted.files.ca, + certPath: trusted.files.clientCert, + keyPath: trusted.files.clientKey, + }); + await expect(connection.workflowService.describeNamespace({ namespace: NAMESPACE })).resolves.toMatchObject({ + namespaceInfo: { name: NAMESPACE }, + }); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('refuses a server certificate from a CA it does not trust', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + await expect(connectVia(proxy.address, { caPath: stranger.files.ca })).rejects.toThrow(); + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('is refused when its client certificate comes from the wrong CA', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + await expect( + connectVia(proxy.address, { + caPath: trusted.files.ca, + certPath: stranger.files.clientCert, + keyPath: stranger.files.clientKey, + }), + ).rejects.toThrow(); + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('sends TEMPORAL_API_KEY as a bearer token inside the TLS session', async () => { + const sink = await startAuthorizationSink(trusted.pki.server); + try { + // the sink answers UNAUTHENTICATED on purpose; the header having arrived is the assertion + await expect(connectVia(sink.address, { apiKey: 'synthetic-key', caPath: trusted.files.ca })).rejects.toThrow(); + expect(sink.authorizations).toContain('Bearer synthetic-key'); + } finally { + await sink.close(); + } + }, 60_000); +}); diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index 8e383fdb7..62588ea04 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -26,7 +26,10 @@ "tsx": "^4.19.3" }, "devDependencies": { + "@temporalio/client": "catalog:", + "@temporalio/testing": "catalog:", "@types/node": "^22.12.0", + "@workflow-builder/tools": "workspace:*", "vitest": "^3.0.4" } } diff --git a/apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts b/apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts new file mode 100644 index 000000000..e9e04dac7 --- /dev/null +++ b/apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts @@ -0,0 +1,143 @@ +// Drives the options this builder produces through the worker's native (Rust) transport: +// a Temporal dev server behind a TLS-terminating proxy, with certificates minted for the +// run. The unit tests next door prove the shape of the options; this file proves they connect. +import { Client } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + type TestPki, + type TestPkiFiles, + createTestPki, + startAuthorizationSink, + startTlsProxy, + writeTestPki, +} from '@workflow-builder/tools/tls-test-harness'; + +import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; + +const NAMESPACE = 'tls-test'; +const TASK_QUEUE = 'tls-probe'; + +const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; + +function config(overrides: Partial): TemporalConnectionConfig { + return { ...empty, ...overrides }; +} + +function connectVia(address: string, overrides: Partial) { + return NativeConnection.connect({ address, ...buildTemporalConnectionOptions(config(overrides)) }); +} + +type Pki = { pki: TestPki; files: TestPkiFiles }; + +function mint(name: string): Pki { + const pki = createTestPki(name); + return { pki, files: writeTestPki(pki, name) }; +} + +describe('Temporal worker over TLS', () => { + let env: TestWorkflowEnvironment; + // `trusted` is what the server presents and requires; `stranger` is a second, unrelated CA. + let trusted: Pki; + let stranger: Pki; + + beforeAll(async () => { + [env, trusted, stranger] = await Promise.all([ + TestWorkflowEnvironment.createLocal({ server: { extraArgs: ['--namespace', NAMESPACE] } }), + mint('trusted'), + mint('stranger'), + ]); + }, 300_000); + + afterAll(async () => { + await env?.teardown(); + }); + + it('connects through a private CA and executes a workflow in a non-default namespace', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const connection = await connectVia(proxy.address, { caPath: trusted.files.ca }); + const worker = await Worker.create({ + connection, + namespace: NAMESPACE, + taskQueue: TASK_QUEUE, + workflowsPath: fileURLToPath(new URL('test-fixtures/tls-probe-workflow.ts', import.meta.url)), + }); + // The client submits over the dev server's own plaintext connection; only the + // worker's polling and completion travel through TLS. + const client = new Client({ connection: env.connection, namespace: NAMESPACE }); + const result = await worker.runUntil( + client.workflow.execute('tlsProbe', { taskQueue: TASK_QUEUE, workflowId: `tls-probe-${Date.now()}` }), + ); + + expect(result).toBe('pong'); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 120_000); + + it('authenticates with a client certificate when the server requires one', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + const connection = await connectVia(proxy.address, { + caPath: trusted.files.ca, + certPath: trusted.files.clientCert, + keyPath: trusted.files.clientKey, + }); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('refuses a server certificate from a CA it does not trust', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + await expect(connectVia(proxy.address, { caPath: stranger.files.ca })).rejects.toThrow(); + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('is refused when its client certificate comes from the wrong CA', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + await expect( + connectVia(proxy.address, { + caPath: trusted.files.ca, + certPath: stranger.files.clientCert, + keyPath: stranger.files.clientKey, + }), + ).rejects.toThrow(); + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('sends TEMPORAL_API_KEY as a bearer token inside the TLS session', async () => { + const sink = await startAuthorizationSink(trusted.pki.server); + try { + // the sink answers UNAUTHENTICATED on purpose; the header having arrived is the assertion + await expect(connectVia(sink.address, { apiKey: 'synthetic-key', caPath: trusted.files.ca })).rejects.toThrow(); + expect(sink.authorizations).toContain('Bearer synthetic-key'); + } finally { + await sink.close(); + } + }, 60_000); +}); diff --git a/apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts b/apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts new file mode 100644 index 000000000..6710f8974 --- /dev/null +++ b/apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts @@ -0,0 +1,5 @@ +// Bundled by path in temporal-connection.tls.test.ts; the real workflows.ts would +// drag the whole plugin in, and the test only needs proof that a task round-trips. +export async function tlsProbe(): Promise { + return 'pong'; +} diff --git a/apps/tools/README.md b/apps/tools/README.md index 998f84383..ba19e3a94 100644 --- a/apps/tools/README.md +++ b/apps/tools/README.md @@ -8,6 +8,10 @@ A collection of scripts and utilities for automating project-specific developmen - **collect-decision-logs**: Compiles a list of `*.decision-log.md` files from the project directory and its subdirectories +### Libraries + +- **tls-test-harness** (`@workflow-builder/tools/tls-test-harness`): throwaway CA + server / client certificates, a TLS-terminating proxy for a plaintext Temporal dev server, and an endpoint that records bearer tokens. Used by the TLS connection tests in `apps/backend` and `apps/execution-worker`; never shipped. + ## Example of usage ```bash diff --git a/apps/tools/package.json b/apps/tools/package.json index d538b0c1b..30a0a5d8e 100644 --- a/apps/tools/package.json +++ b/apps/tools/package.json @@ -3,14 +3,21 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": { + "./tls-test-harness": "./src/tls-test-harness/index.ts" + }, "scripts": { "collect-decision-logs": "tsx ./src/scripts/collect-decision-logs.ts", "typecheck": "tsc --noEmit", "lint": "eslint", "lint:fix": "eslint --fix" }, + "dependencies": { + "node-forge": "^1.4.0" + }, "devDependencies": { "@types/node": "^22.12.0", + "@types/node-forge": "^1.3.14", "chalk": "^5.4.1", "remeda": "^2.19.2" } diff --git a/apps/tools/src/tls-test-harness/authorization-sink.ts b/apps/tools/src/tls-test-harness/authorization-sink.ts new file mode 100644 index 000000000..3629b381f --- /dev/null +++ b/apps/tools/src/tls-test-harness/authorization-sink.ts @@ -0,0 +1,47 @@ +import { type Http2SecureServer, type Http2Session, createSecureServer } from 'node:http2'; +import type { AddressInfo } from 'node:net'; + +import type { PemPair } from './certificates'; + +export type AuthorizationSink = { + address: string; + /** The `authorization` header of every gRPC call received, in order. */ + authorizations: string[]; + close: () => Promise; +}; + +/** + * A TLS endpoint that records the `authorization` header of each gRPC request and + * answers UNAUTHENTICATED, so a client's connect attempt fails fast instead of hanging. + * The header travels inside the encrypted HTTP/2 stream, so a TCP-level proxy cannot see it. + */ +export async function startAuthorizationSink(server: PemPair): Promise { + const authorizations: string[] = []; + const sessions = new Set(); + + const http2Server: Http2SecureServer = createSecureServer({ cert: server.cert, key: server.key, allowHTTP1: false }); + http2Server.on('session', (session) => { + sessions.add(session); + session.on('close', () => sessions.delete(session)); + }); + http2Server.on('stream', (stream, headers) => { + authorizations.push(String(headers.authorization ?? '')); + stream.respond( + { ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '16', 'grpc-message': 'authorization sink' }, + { endStream: true }, + ); + }); + + await new Promise((resolve) => http2Server.listen(0, resolve)); + const { port } = http2Server.address() as AddressInfo; + + return { + address: `localhost:${port}`, + authorizations, + close: () => + new Promise((resolve) => { + for (const session of sessions) session.destroy(); + http2Server.close(() => resolve()); + }), + }; +} diff --git a/apps/tools/src/tls-test-harness/certificates.ts b/apps/tools/src/tls-test-harness/certificates.ts new file mode 100644 index 000000000..886354b6b --- /dev/null +++ b/apps/tools/src/tls-test-harness/certificates.ts @@ -0,0 +1,77 @@ +import forge from 'node-forge'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +export type PemPair = { cert: string; key: string }; + +/** A throwaway CA with one server leaf (SAN localhost / 127.0.0.1 / ::1) and one client leaf. */ +export type TestPki = { ca: PemPair; server: PemPair; client: PemPair }; + +/** The PEM files a TEMPORAL_TLS_*_PATH-style config can point at. */ +export type TestPkiFiles = { ca: string; clientCert: string; clientKey: string }; + +type Issued = { cert: forge.pki.Certificate; key: forge.pki.rsa.PrivateKey; pem: PemPair }; + +let nextSerial = 1; + +function issue(commonName: string, issuer: Issued | null, extensions: object[]): Issued { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = (nextSerial++).toString(16).padStart(2, '0'); + cert.validity.notBefore = new Date(Date.now() - 60 * 60 * 1000); + cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000); + const subject = [{ name: 'commonName', value: commonName }]; + cert.setSubject(subject); + cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject); + cert.setExtensions(extensions); + // rustls, the worker's native transport, rejects forge's default SHA-1 signature. + cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create()); + return { + cert, + key: keys.privateKey, + pem: { cert: forge.pki.certificateToPem(cert), key: forge.pki.privateKeyToPem(keys.privateKey) }, + }; +} + +export function createTestPki(name: string): TestPki { + const ca = issue(`${name} test CA`, null, [ + { name: 'basicConstraints', cA: true, critical: true }, + { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true }, + { name: 'subjectKeyIdentifier' }, + ]); + const server = issue(`${name} server`, ca, [ + { name: 'basicConstraints', cA: false, critical: true }, + { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true }, + { name: 'extKeyUsage', serverAuth: true }, + { + name: 'subjectAltName', + altNames: [ + { type: 2, value: 'localhost' }, + { type: 7, ip: '127.0.0.1' }, + { type: 7, ip: '::1' }, + ], + }, + ]); + const client = issue(`${name} client`, ca, [ + { name: 'basicConstraints', cA: false, critical: true }, + { name: 'keyUsage', digitalSignature: true, critical: true }, + { name: 'extKeyUsage', clientAuth: true }, + ]); + return { ca: ca.pem, server: server.pem, client: client.pem }; +} + +/** Writes the CA and client PEMs to a fresh temp directory, so config paths resolve like in production. */ +export function writeTestPki(pki: TestPki, name: string): TestPkiFiles { + const directory = mkdtempSync(path.join(tmpdir(), `wb-tls-${name}-`)); + const files = { + ca: path.join(directory, 'ca.pem'), + clientCert: path.join(directory, 'client.pem'), + clientKey: path.join(directory, 'client-key.pem'), + }; + writeFileSync(files.ca, pki.ca.cert); + writeFileSync(files.clientCert, pki.client.cert); + writeFileSync(files.clientKey, pki.client.key); + return files; +} diff --git a/apps/tools/src/tls-test-harness/index.ts b/apps/tools/src/tls-test-harness/index.ts new file mode 100644 index 000000000..822adba64 --- /dev/null +++ b/apps/tools/src/tls-test-harness/index.ts @@ -0,0 +1,6 @@ +// Test-only TLS harness for the Temporal connection builders in apps/backend and +// apps/execution-worker: throwaway certificates, a TLS-terminating proxy in front of +// a plaintext dev server, and an endpoint that records bearer tokens. +export { type PemPair, type TestPki, type TestPkiFiles, createTestPki, writeTestPki } from './certificates'; +export { type AuthorizationSink, startAuthorizationSink } from './authorization-sink'; +export { type TlsProxy, type TlsProxyOptions, startTlsProxy } from './tls-proxy'; diff --git a/apps/tools/src/tls-test-harness/tls-proxy.ts b/apps/tools/src/tls-test-harness/tls-proxy.ts new file mode 100644 index 000000000..a23e7fc5d --- /dev/null +++ b/apps/tools/src/tls-test-harness/tls-proxy.ts @@ -0,0 +1,73 @@ +import { type AddressInfo, type Socket, connect } from 'node:net'; +import { type TlsOptions, createServer } from 'node:tls'; + +import type { PemPair } from './certificates'; + +export type TlsProxy = { + /** host:port a Temporal client can dial; the hostname is covered by the server certificate's SAN. */ + address: string; + /** One entry per failed handshake, whichever side aborted it. */ + handshakeErrors: string[]; + close: () => Promise; +}; + +export type TlsProxyOptions = { + /** host:port of the plaintext Temporal server behind the proxy. */ + upstream: string; + server: PemPair; + /** When set, a client certificate signed by this CA is required. */ + clientCa?: string; +}; + +/** + * Terminates TLS in front of a plaintext Temporal server and forwards the bytes as-is. + * gRPC frames pass through untouched, so what is exercised is the client's transport: + * server-certificate trust, hostname check, ALPN and, with `clientCa`, mutual TLS. + */ +export async function startTlsProxy({ upstream, server, clientCa }: TlsProxyOptions): Promise { + const [upstreamHost, upstreamPort] = splitAddress(upstream); + const handshakeErrors: string[] = []; + const sockets = new Set(); + + const options: TlsOptions = { + cert: server.cert, + key: server.key, + // gRPC clients hang up on a server that does not select h2 + ALPNProtocols: ['h2'], + ...(clientCa ? { ca: clientCa, requestCert: true, rejectUnauthorized: true } : {}), + }; + + const tlsServer = createServer(options, (downstream) => { + const upstreamSocket = connect({ host: upstreamHost, port: upstreamPort }); + sockets.add(downstream); + sockets.add(upstreamSocket); + downstream.pipe(upstreamSocket).pipe(downstream); + const drop = () => { + downstream.destroy(); + upstreamSocket.destroy(); + }; + downstream.on('error', drop); + upstreamSocket.on('error', drop); + downstream.on('close', drop); + upstreamSocket.on('close', drop); + }); + tlsServer.on('tlsClientError', (error) => handshakeErrors.push(error.message)); + + await new Promise((resolve) => tlsServer.listen(0, resolve)); + const { port } = tlsServer.address() as AddressInfo; + + return { + address: `localhost:${port}`, + handshakeErrors, + close: () => + new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + tlsServer.close(() => resolve()); + }), + }; +} + +function splitAddress(address: string): [string, number] { + const separator = address.lastIndexOf(':'); + return [address.slice(0, separator), Number(address.slice(separator + 1))]; +} diff --git a/knip.config.js b/knip.config.js index e5b8cc0bc..ad00d1ee7 100644 --- a/knip.config.js +++ b/knip.config.js @@ -29,7 +29,8 @@ export default { ignoreDependencies: ['@phosphor-icons/core', '@svgr/core'], }, 'apps/tools': { - entry: ['src/scripts/*.ts'], + // tls-test-harness is imported by the backend and worker TLS tests via the package exports + entry: ['src/scripts/*.ts', 'src/tls-test-harness/index.ts'], project: 'src/**/*.ts', }, 'packages/types': { @@ -49,7 +50,12 @@ export default { entry: ['src/index.ts'], }, 'apps/execution-worker': { - entry: ['src/engines/temporal/worker.ts', 'src/engines/temporal/workflows.ts'], + // test-fixtures/tls-probe-workflow.ts is handed to Temporal's bundler by path, so nothing imports it + entry: [ + 'src/engines/temporal/worker.ts', + 'src/engines/temporal/workflows.ts', + 'src/engines/temporal/test-fixtures/tls-probe-workflow.ts', + ], // @temporalio/workflow is never imported by this app's code, but Temporal's // workflow bundler resolves it from *here* while compiling workflows.ts (the // re-exported runner imports it), so it has to be installed in this workspace. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49afd9654..90b4dcea8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,9 +253,15 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@temporalio/testing': + specifier: 'catalog:' + version: 1.23.0 '@types/node': specifier: ^22.12.0 version: 22.12.0 + '@workflow-builder/tools': + specifier: workspace:* + version: link:../tools drizzle-kit: specifier: ^0.31.0 version: 0.31.10 @@ -475,9 +481,18 @@ importers: specifier: ^4.19.3 version: 4.21.0 devDependencies: + '@temporalio/client': + specifier: 'catalog:' + version: 1.23.0 + '@temporalio/testing': + specifier: 'catalog:' + version: 1.23.0(tslib@2.8.1) '@types/node': specifier: ^22.12.0 version: 22.12.0 + '@workflow-builder/tools': + specifier: workspace:* + version: link:../tools vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) @@ -505,10 +520,17 @@ importers: version: 19.1.0 apps/tools: + dependencies: + node-forge: + specifier: ^1.4.0 + version: 1.4.0 devDependencies: '@types/node': specifier: ^22.12.0 version: 22.12.0 + '@types/node-forge': + specifier: ^1.3.14 + version: 1.3.14 chalk: specifier: ^5.4.1 version: 5.4.1 @@ -3123,6 +3145,9 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node-forge@1.3.14': + resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -6262,6 +6287,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} @@ -10511,6 +10540,31 @@ snapshots: long: 5.3.2 protobufjs: 8.8.0 + '@temporalio/testing@1.23.0': + dependencies: + '@temporalio/activity': 1.23.0 + '@temporalio/client': 1.23.0 + '@temporalio/common': 1.23.0 + '@temporalio/core-bridge': 1.23.0 + '@temporalio/proto': 1.23.0 + '@temporalio/worker': 1.23.0 + '@temporalio/workflow': 1.23.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - tslib + - uglify-js + - webpack-cli + '@temporalio/testing@1.23.0(esbuild@0.27.3)(postcss@8.5.6)(tslib@2.8.1)': dependencies: '@temporalio/activity': 1.23.0 @@ -10536,6 +10590,69 @@ snapshots: - uglify-js - webpack-cli + '@temporalio/testing@1.23.0(tslib@2.8.1)': + dependencies: + '@temporalio/activity': 1.23.0 + '@temporalio/client': 1.23.0 + '@temporalio/common': 1.23.0 + '@temporalio/core-bridge': 1.23.0 + '@temporalio/proto': 1.23.0 + '@temporalio/worker': 1.23.0(tslib@2.8.1) + '@temporalio/workflow': 1.23.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - tslib + - uglify-js + - webpack-cli + + '@temporalio/worker@1.23.0': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@swc/core': 1.15.26 + '@temporalio/activity': 1.23.0 + '@temporalio/client': 1.23.0 + '@temporalio/common': 1.23.0 + '@temporalio/core-bridge': 1.23.0 + '@temporalio/nexus': 1.23.0 + '@temporalio/proto': 1.23.0 + '@temporalio/workflow': 1.23.0 + heap-js: 2.7.1 + memfs: 4.57.2(tslib@2.8.1) + nexus-rpc: 0.0.3 + protobufjs: 8.8.0 + rxjs: 7.8.1 + source-map: 0.7.6 + source-map-loader: 5.0.0(webpack@5.110.1(@swc/core@1.15.26)) + supports-color: 8.1.1 + swc-loader: 0.2.7(@swc/core@1.15.26)(webpack@5.110.1(@swc/core@1.15.26)) + unionfs: 4.6.0 + webpack: 5.110.1(@swc/core@1.15.26) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - tslib + - uglify-js + - webpack-cli + '@temporalio/worker@1.23.0(esbuild@0.27.3)(postcss@8.5.6)(tslib@2.8.1)': dependencies: '@grpc/grpc-js': 1.14.3 @@ -10874,6 +10991,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node-forge@1.3.14': + dependencies: + '@types/node': 22.12.0 + '@types/node@12.20.55': {} '@types/node@17.0.45': {} @@ -14845,6 +14966,8 @@ snapshots: node-fetch-native@1.6.7: {} + node-forge@1.4.0: {} + node-mock-http@1.0.4: {} node-releases@2.0.37: {} From e04466b5d7354924dec9a7253c03557ce1177ed2 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 14:48:13 +0200 Subject: [PATCH 22/27] refactor(temporal-connection): one copy of the TEMPORAL_* rules Backend and worker carried identical validation, TLS inference and certificate reading, kept aligned only by a "keep in sync" comment, plus duplicated defaults for the address and namespace. The option types were the excuse, but both SDKs accept a plain apiKey string and the same tls shape, so a narrow shared contract fits both uncast. Move all of it into a private source-only workspace, @workflow-builder/temporal-connection, behind a single temporalConfig() returning { connection, namespace }; each app hands the connection object to its SDK's connect call. Reading and validation still happen where they did: in the backend's first-connection factory and before the worker starts. An empty TEMPORAL_ADDRESS or TEMPORAL_NAMESPACE now falls back to the default like the other variables already did. The validation matrix lives once in the new package, and so do the TLS connection tests and their harness, a describe.each over both SDK transports; handing the built options to both connect calls there is the compile-time proof of assignability. The app suites are pure unit tests again and apps/tools is untouched. The workspace is added to the CI execution job's filter list, knip and CLAUDE.md. --- .github/workflows/pr-check.yml | 14 +- CLAUDE.md | 25 ++- apps/backend/README.md | 4 +- apps/backend/package.json | 3 +- apps/backend/src/engine/index.ts | 18 +- .../src/engine/temporal-connection.test.ts | 101 --------- .../engine/temporal-connection.tls.test.ts | 149 ------------- .../backend/src/engine/temporal-connection.ts | 79 ------- apps/backend/src/env.ts | 12 +- apps/execution-worker/README.md | 10 +- apps/execution-worker/package.json | 4 +- .../temporal/temporal-connection.test.ts | 104 --------- .../temporal/temporal-connection.tls.test.ts | 143 ------------ .../engines/temporal/temporal-connection.ts | 79 ------- .../test-fixtures/tls-probe-workflow.ts | 5 - .../src/engines/temporal/worker.ts | 19 +- apps/execution-worker/src/env.ts | 13 +- apps/tools/README.md | 4 - apps/tools/package.json | 7 - apps/tools/src/tls-test-harness/index.ts | 6 - knip.config.js | 18 +- packages/temporal-connection/README.md | 20 ++ .../temporal-connection/eslint.config.mjs | 1 + .../lint-staged.config.mjs | 1 + packages/temporal-connection/package.json | 26 +++ .../temporal-connection/src/index.test.ts | 140 ++++++++++++ packages/temporal-connection/src/index.ts | 119 ++++++++++ .../test/fixtures/tls-probe-workflow.ts | 5 + .../test/harness}/authorization-sink.ts | 2 +- .../test/harness}/certificates.ts | 0 .../temporal-connection/test/harness/index.ts | 5 + .../test/harness}/tls-proxy.ts | 4 +- packages/temporal-connection/test/tls.test.ts | 206 ++++++++++++++++++ packages/temporal-connection/tsconfig.json | 8 + pnpm-lock.yaml | 128 +++-------- 35 files changed, 623 insertions(+), 859 deletions(-) delete mode 100644 apps/backend/src/engine/temporal-connection.test.ts delete mode 100644 apps/backend/src/engine/temporal-connection.tls.test.ts delete mode 100644 apps/backend/src/engine/temporal-connection.ts delete mode 100644 apps/execution-worker/src/engines/temporal/temporal-connection.test.ts delete mode 100644 apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts delete mode 100644 apps/execution-worker/src/engines/temporal/temporal-connection.ts delete mode 100644 apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts delete mode 100644 apps/tools/src/tls-test-harness/index.ts create mode 100644 packages/temporal-connection/README.md create mode 100644 packages/temporal-connection/eslint.config.mjs create mode 100644 packages/temporal-connection/lint-staged.config.mjs create mode 100644 packages/temporal-connection/package.json create mode 100644 packages/temporal-connection/src/index.test.ts create mode 100644 packages/temporal-connection/src/index.ts create mode 100644 packages/temporal-connection/test/fixtures/tls-probe-workflow.ts rename {apps/tools/src/tls-test-harness => packages/temporal-connection/test/harness}/authorization-sink.ts (98%) rename {apps/tools/src/tls-test-harness => packages/temporal-connection/test/harness}/certificates.ts (100%) create mode 100644 packages/temporal-connection/test/harness/index.ts rename {apps/tools/src/tls-test-harness => packages/temporal-connection/test/harness}/tls-proxy.ts (97%) create mode 100644 packages/temporal-connection/test/tls.test.ts create mode 100644 packages/temporal-connection/tsconfig.json diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index d034a1f43..10a9d4f71 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -220,10 +220,10 @@ jobs: name: Execution pipeline lint + typecheck + test runs-on: ubuntu-latest # No `services:` block: the suites run against in-memory fakes — no Postgres, - # no API keys. The one exception is the TLS connection tests in backend and - # worker, which start Temporal's dev server themselves (@temporalio/testing - # downloads the CLI on first run). If a suite here ever needs infra it cannot - # start itself, give it its own job rather than adding services to this one. + # no API keys. The one exception is temporal-connection's TLS test, which + # starts Temporal's dev server itself (@temporalio/testing downloads the CLI + # on first run). If a suite here ever needs infra it cannot start itself, + # give it its own job rather than adding services to this one. steps: - name: Checkout code uses: actions/checkout@v4 @@ -245,10 +245,10 @@ jobs: run: pnpm install --frozen-lockfile - name: Lint - run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/backend --filter @workflow-builder/execution-worker lint + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker lint - name: Typecheck - run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/backend --filter @workflow-builder/execution-worker typecheck + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker typecheck - name: Test - run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/backend --filter @workflow-builder/execution-worker test + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker test diff --git a/CLAUDE.md b/CLAUDE.md index f392f2d73..eac4d5ac1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ packages/ tokens/ - @workflowbuilder/ui-tokens private design-token build (style-dictionary), feeds packages/ui execution-core/ - Pure topological graph runner + node executor registry temporal/ - @workflowbuilder/temporal published Temporal Plugin (activities + workflow runner); bundles execution-core + types into its dist + temporal-connection/ - Private, source-only: TEMPORAL_* env -> validated connection options + namespace, one copy shared by backend and worker types/ - Shared TypeScript types ``` @@ -74,21 +75,23 @@ Where to put a new script: root `tools/` for pure-Node bootstrap (runs before an Each workspace has its own context. Read the relevant file before extending a workspace. -| Workspace | Authoritative docs | -| ------------------------- | ------------------------------------------------------- | -| `packages/sdk` | `packages/sdk/README.md` | -| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) | -| `packages/tokens` | `packages/tokens/README.md` | -| `packages/execution-core` | `packages/execution-core/README.md` | -| `packages/temporal` | `packages/temporal/README.md` | -| `apps/demo` | `apps/demo/CLAUDE.md` | -| `apps/ai-studio` | `apps/ai-studio/README.md` | -| `apps/backend` | `apps/backend/README.md` | -| `apps/execution-worker` | `apps/execution-worker/README.md` | +| Workspace | Authoritative docs | +| ------------------------------ | ------------------------------------------------------- | +| `packages/sdk` | `packages/sdk/README.md` | +| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) | +| `packages/tokens` | `packages/tokens/README.md` | +| `packages/execution-core` | `packages/execution-core/README.md` | +| `packages/temporal` | `packages/temporal/README.md` | +| `packages/temporal-connection` | `packages/temporal-connection/README.md` | +| `apps/demo` | `apps/demo/CLAUDE.md` | +| `apps/ai-studio` | `apps/ai-studio/README.md` | +| `apps/backend` | `apps/backend/README.md` | +| `apps/execution-worker` | `apps/execution-worker/README.md` | ## Types & Aliases Shared types: `packages/types/` (imported as `@workflow-builder/types/*`). +Temporal connection config: `packages/temporal-connection/` (imported as `@workflow-builder/temporal-connection`; `temporalConfig()` gives backend and worker their connect options and namespace). Icons: `apps/icons/` (imported as `@workflow-builder/icons`). SDK: `packages/sdk/` (imported as `@workflowbuilder/sdk`). UI: `packages/ui/` (imported as `@workflowbuilder/ui`; styles via `@workflowbuilder/ui/styles.css`, `/index.css`, `/tokens.css`). diff --git a/apps/backend/README.md b/apps/backend/README.md index 9f6a47100..311ea1da8 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -63,7 +63,9 @@ runs everything except AI Agent nodes. See [`apps/execution-worker/README.md`](. ### Connecting to a secured Temporal cluster The defaults above open a plaintext connection to the bundled dev cluster. Everything about the -connection is env-driven, so a hardened cluster or Temporal Cloud needs no code change: +connection is env-driven, so a hardened cluster or Temporal Cloud needs no code change. The +variables are read and validated by [`@workflow-builder/temporal-connection`](../../packages/temporal-connection/README.md), +the same code the worker uses: | Var | Purpose | Default | | ------------------------ | ------------------------------------------------------------ | ------------- | diff --git a/apps/backend/package.json b/apps/backend/package.json index 2b6bf7eed..4e70bc611 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -21,6 +21,7 @@ "@hono/node-server": "^1.14.0", "@temporalio/client": "catalog:", "@workflow-builder/execution-core": "workspace:*", + "@workflow-builder/temporal-connection": "workspace:*", "@workflow-builder/types": "workspace:*", "@workflowbuilder/temporal": "workspace:*", "ai": "catalog:", @@ -33,9 +34,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@temporalio/testing": "catalog:", "@types/node": "^22.12.0", - "@workflow-builder/tools": "workspace:*", "drizzle-kit": "^0.31.0", "vitest": "^3.0.4" } diff --git a/apps/backend/src/engine/index.ts b/apps/backend/src/engine/index.ts index 228bf05ff..b4d641dce 100644 --- a/apps/backend/src/engine/index.ts +++ b/apps/backend/src/engine/index.ts @@ -2,11 +2,9 @@ import { Client, Connection } from '@temporalio/client'; import { TemporalWorkflowEngine } from '@workflowbuilder/temporal/client'; import type { WorkflowEnginePort } from '@workflow-builder/execution-core/workflow'; +import { temporalConfig } from '@workflow-builder/temporal-connection'; import type { BaseNode } from '@workflow-builder/types/workflow-execution/execution-model'; -import { env } from '../env'; -import { buildTemporalConnectionOptions } from './temporal-connection'; - let engine: WorkflowEnginePort | undefined; export function getWorkflowEngine(): WorkflowEnginePort { @@ -17,17 +15,9 @@ export function getWorkflowEngine(): WorkflowEnginePort { // Misconfigured TEMPORAL_* values therefore surface on that first submit // rather than at boot. client: async () => { - const connection = await Connection.connect({ - address: env.TEMPORAL_ADDRESS, - ...buildTemporalConnectionOptions({ - tls: env.TEMPORAL_TLS, - apiKey: env.TEMPORAL_API_KEY, - caPath: env.TEMPORAL_TLS_CA_PATH, - certPath: env.TEMPORAL_TLS_CERT_PATH, - keyPath: env.TEMPORAL_TLS_KEY_PATH, - }), - }); - return new Client({ connection, namespace: env.TEMPORAL_NAMESPACE }); + const temporal = temporalConfig(); + const connection = await Connection.connect(temporal.connection); + return new Client({ connection, namespace: temporal.namespace }); }, }); } diff --git a/apps/backend/src/engine/temporal-connection.test.ts b/apps/backend/src/engine/temporal-connection.test.ts deleted file mode 100644 index 9cf309a36..000000000 --- a/apps/backend/src/engine/temporal-connection.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; - -const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; - -function config(overrides: Partial): TemporalConnectionConfig { - return { ...empty, ...overrides }; -} - -// Keyed by path so a test can tell the CA apart from the client cert. -function fakeReader() { - return vi.fn((path: string) => new TextEncoder().encode(`contents-of:${path}`)); -} - -function bytes(path: string) { - return new TextEncoder().encode(`contents-of:${path}`); -} - -describe('buildTemporalConnectionOptions', () => { - it('stays plaintext when nothing is configured — the local-dev default', () => { - expect(buildTemporalConnectionOptions(empty, fakeReader())).toEqual({}); - }); - - it('enables TLS with the OS trust store on TEMPORAL_TLS=true', () => { - expect(buildTemporalConnectionOptions(config({ tls: 'true' }), fakeReader())).toEqual({ tls: true }); - }); - - it('stays plaintext on an explicit TEMPORAL_TLS=false', () => { - expect(buildTemporalConnectionOptions(config({ tls: 'false' }), fakeReader())).toEqual({}); - }); - - // Mirrors the SDK's own normalizeTlsConfig, which turns TLS on whenever an - // apiKey is present. Temporal Cloud rejects an API key sent in the clear. - it('infers TLS from an API key alone', () => { - expect(buildTemporalConnectionOptions(config({ apiKey: 'tmprl-key' }), fakeReader())).toEqual({ - tls: true, - apiKey: 'tmprl-key', - }); - }); - - it('loads a private CA certificate', () => { - const read = fakeReader(); - - expect(buildTemporalConnectionOptions(config({ caPath: '/certs/ca.pem' }), read)).toEqual({ - tls: { serverRootCACertificate: bytes('/certs/ca.pem') }, - }); - expect(read).toHaveBeenCalledWith('/certs/ca.pem'); - }); - - it('loads a full mTLS pair alongside the CA', () => { - const options = buildTemporalConnectionOptions( - config({ caPath: '/certs/ca.pem', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }), - fakeReader(), - ); - - expect(options).toEqual({ - tls: { - serverRootCACertificate: bytes('/certs/ca.pem'), - clientCertPair: { crt: bytes('/certs/client.pem'), key: bytes('/certs/client.key') }, - }, - }); - }); -}); - -describe('buildTemporalConnectionOptions rejects contradictory config at connect time', () => { - it('refuses half an mTLS pair', () => { - expect(() => buildTemporalConnectionOptions(config({ certPath: '/certs/client.pem' }), fakeReader())).toThrow( - /must be set together/, - ); - expect(() => buildTemporalConnectionOptions(config({ keyPath: '/certs/client.key' }), fakeReader())).toThrow( - /must be set together/, - ); - }); - - it('refuses an API key and a client certificate together', () => { - const both = config({ apiKey: 'k', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }); - - expect(() => buildTemporalConnectionOptions(both, fakeReader())).toThrow(/not both/); - }); - - it('refuses credentials that TEMPORAL_TLS=false would silently discard', () => { - const contradiction = config({ tls: 'false', apiKey: 'k' }); - - expect(() => buildTemporalConnectionOptions(contradiction, fakeReader())).toThrow(/contradicts/); - }); - - it('refuses a TEMPORAL_TLS value that is neither true nor false', () => { - expect(() => buildTemporalConnectionOptions(config({ tls: 'yes' }), fakeReader())).toThrow(/must be 'true'/); - }); - - it('names the variable and the path when a certificate cannot be read', () => { - const explode = vi.fn(() => { - throw new Error('ENOENT'); - }); - - expect(() => buildTemporalConnectionOptions(config({ caPath: '/nope.pem' }), explode)).toThrow( - /TEMPORAL_TLS_CA_PATH \(\/nope\.pem\)/, - ); - }); -}); diff --git a/apps/backend/src/engine/temporal-connection.tls.test.ts b/apps/backend/src/engine/temporal-connection.tls.test.ts deleted file mode 100644 index 6e8f46f55..000000000 --- a/apps/backend/src/engine/temporal-connection.tls.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// Drives the options this builder produces through a real TLS handshake: a Temporal -// dev server behind a TLS-terminating proxy, with certificates minted for the run. -// The unit tests next door prove the shape of the options; this file proves they connect. -import { Client, Connection } from '@temporalio/client'; -import { TestWorkflowEnvironment } from '@temporalio/testing'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -import { - type TestPki, - type TestPkiFiles, - createTestPki, - startAuthorizationSink, - startTlsProxy, - writeTestPki, -} from '@workflow-builder/tools/tls-test-harness'; - -import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; - -const NAMESPACE = 'tls-test'; -const CONNECT_TIMEOUT = '3s'; - -const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; - -function config(overrides: Partial): TemporalConnectionConfig { - return { ...empty, ...overrides }; -} - -function connectVia(address: string, overrides: Partial) { - return Connection.connect({ - address, - connectTimeout: CONNECT_TIMEOUT, - ...buildTemporalConnectionOptions(config(overrides)), - }); -} - -type Pki = { pki: TestPki; files: TestPkiFiles }; - -function mint(name: string): Pki { - const pki = createTestPki(name); - return { pki, files: writeTestPki(pki, name) }; -} - -describe('Temporal client over TLS', () => { - let env: TestWorkflowEnvironment; - // `trusted` is what the server presents and requires; `stranger` is a second, unrelated CA. - let trusted: Pki; - let stranger: Pki; - - beforeAll(async () => { - [env, trusted, stranger] = await Promise.all([ - TestWorkflowEnvironment.createLocal({ server: { extraArgs: ['--namespace', NAMESPACE] } }), - mint('trusted'), - mint('stranger'), - ]); - }, 300_000); - - afterAll(async () => { - await env?.teardown(); - }); - - it('connects through a private CA and starts a workflow in a non-default namespace', async () => { - const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); - try { - const connection = await connectVia(proxy.address, { caPath: trusted.files.ca }); - const client = new Client({ connection, namespace: NAMESPACE }); - const handle = await client.workflow.start('tls-probe', { - taskQueue: 'tls-probe', - workflowId: `tls-probe-${Date.now()}`, - }); - - // Read back over the dev server's own plaintext connection, so the assertion - // does not depend on the connection under test. - const inNamespace = new Client({ connection: env.connection, namespace: NAMESPACE }); - await expect(inNamespace.workflow.getHandle(handle.workflowId).describe()).resolves.toMatchObject({ - status: { name: 'RUNNING' }, - }); - await expect(env.client.workflow.getHandle(handle.workflowId).describe()).rejects.toThrow(); - - await handle.terminate('tls test done'); - await connection.close(); - expect(proxy.handshakeErrors).toEqual([]); - } finally { - await proxy.close(); - } - }, 60_000); - - it('authenticates with a client certificate when the server requires one', async () => { - const proxy = await startTlsProxy({ - upstream: env.address, - server: trusted.pki.server, - clientCa: trusted.pki.ca.cert, - }); - try { - const connection = await connectVia(proxy.address, { - caPath: trusted.files.ca, - certPath: trusted.files.clientCert, - keyPath: trusted.files.clientKey, - }); - await expect(connection.workflowService.describeNamespace({ namespace: NAMESPACE })).resolves.toMatchObject({ - namespaceInfo: { name: NAMESPACE }, - }); - await connection.close(); - expect(proxy.handshakeErrors).toEqual([]); - } finally { - await proxy.close(); - } - }, 60_000); - - it('refuses a server certificate from a CA it does not trust', async () => { - const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); - try { - await expect(connectVia(proxy.address, { caPath: stranger.files.ca })).rejects.toThrow(); - await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); - } finally { - await proxy.close(); - } - }, 60_000); - - it('is refused when its client certificate comes from the wrong CA', async () => { - const proxy = await startTlsProxy({ - upstream: env.address, - server: trusted.pki.server, - clientCa: trusted.pki.ca.cert, - }); - try { - await expect( - connectVia(proxy.address, { - caPath: trusted.files.ca, - certPath: stranger.files.clientCert, - keyPath: stranger.files.clientKey, - }), - ).rejects.toThrow(); - await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); - } finally { - await proxy.close(); - } - }, 60_000); - - it('sends TEMPORAL_API_KEY as a bearer token inside the TLS session', async () => { - const sink = await startAuthorizationSink(trusted.pki.server); - try { - // the sink answers UNAUTHENTICATED on purpose; the header having arrived is the assertion - await expect(connectVia(sink.address, { apiKey: 'synthetic-key', caPath: trusted.files.ca })).rejects.toThrow(); - expect(sink.authorizations).toContain('Bearer synthetic-key'); - } finally { - await sink.close(); - } - }, 60_000); -}); diff --git a/apps/backend/src/engine/temporal-connection.ts b/apps/backend/src/engine/temporal-connection.ts deleted file mode 100644 index 342855f7e..000000000 --- a/apps/backend/src/engine/temporal-connection.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Turns the TEMPORAL_* env vars into Temporal connection options. -// -// Duplicated by design in apps/execution-worker/src/engines/temporal/temporal-connection.ts: -// the two SDKs type their options separately (the client accepts a function for -// `apiKey`, the worker's native connection only a string), so a shared module would -// have to pick one and cast. Keep the two copies in sync. -import type { ConnectionOptions } from '@temporalio/client'; -import { readFileSync } from 'node:fs'; - -export type TemporalConnectionConfig = { - // Raw TEMPORAL_TLS. Tri-state on purpose: unset means "infer from the rest", - // which is not the same as an explicit 'false'. - tls: string | null; - apiKey: string | null; - caPath: string | null; - certPath: string | null; - keyPath: string | null; -}; - -type TemporalConnectionOptions = Pick; - -export function buildTemporalConnectionOptions( - config: TemporalConnectionConfig, - readFile: (path: string) => Uint8Array = readFileSync, -): TemporalConnectionOptions { - const { tls, apiKey, caPath, certPath, keyPath } = config; - - if (tls !== null && tls !== 'true' && tls !== 'false') { - throw new Error(`TEMPORAL_TLS must be 'true' or 'false' (got '${tls}').`); - } - if (Boolean(certPath) !== Boolean(keyPath)) { - throw new Error( - 'TEMPORAL_TLS_CERT_PATH and TEMPORAL_TLS_KEY_PATH must be set together — mTLS needs both halves of the pair.', - ); - } - if (apiKey && certPath) { - throw new Error('Set either TEMPORAL_API_KEY or an mTLS client certificate pair, not both.'); - } - - const hasTlsMaterial = Boolean(apiKey || caPath || certPath); - if (tls === 'false' && hasTlsMaterial) { - throw new Error( - 'TEMPORAL_TLS=false contradicts the TEMPORAL_API_KEY / TEMPORAL_TLS_*_PATH values that are set — remove one side.', - ); - } - - // Material implies TLS, matching what the SDK already does for apiKey. Being - // explicit here keeps the client and the worker in step and makes it testable. - if (tls !== 'true' && !hasTlsMaterial) { - // Plaintext — the local-dev default, and what this backend did before. - return {}; - } - - const certificates = { - ...(caPath ? { serverRootCACertificate: read(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), - ...(certPath && keyPath - ? { - clientCertPair: { - crt: read(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), - key: read(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), - }, - } - : {}), - }; - - return { - // `true` means TLS with the OS trust store — enough for Temporal Cloud. - tls: Object.keys(certificates).length > 0 ? certificates : true, - ...(apiKey ? { apiKey } : {}), - }; -} - -function read(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { - try { - return readFile(path); - } catch (error) { - throw new Error(`Could not read ${variable} (${path}).`, { cause: error }); - } -} diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 9bf17c577..c9410648c 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -17,17 +17,7 @@ export const env = { PORT: Number(envOr('PORT', '3001')), HOST: envOr('HOST', '127.0.0.1'), DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), - TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'), - // Must match the worker's. Temporal Cloud spells it `.`. - TEMPORAL_NAMESPACE: envOr('TEMPORAL_NAMESPACE', 'default'), - // Unset means "infer": any of the credentials below turns TLS on. Set it to - // 'true' to require TLS on its own, or 'false' to assert plaintext. - TEMPORAL_TLS: envOptional('TEMPORAL_TLS'), - TEMPORAL_API_KEY: envOptional('TEMPORAL_API_KEY'), - // Paths, read at connect time. CA for a private issuer; the cert/key pair for mTLS. - TEMPORAL_TLS_CA_PATH: envOptional('TEMPORAL_TLS_CA_PATH'), - TEMPORAL_TLS_CERT_PATH: envOptional('TEMPORAL_TLS_CERT_PATH'), - TEMPORAL_TLS_KEY_PATH: envOptional('TEMPORAL_TLS_KEY_PATH'), + // TEMPORAL_*: read at connect time by @workflow-builder/temporal-connection // 0 disables (dev default); the deploy compose sets both RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')), RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')), diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index ed99a83cd..04120ea7e 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -49,15 +49,17 @@ own network — and no request leaves that network. There is no built-in endpoin The connection to Temporal is env-driven too: `TEMPORAL_TLS`, `TEMPORAL_API_KEY` and the `TEMPORAL_TLS_CA_PATH` / `TEMPORAL_TLS_CERT_PATH` / `TEMPORAL_TLS_KEY_PATH` trio cover a hardened -cluster or Temporal Cloud. The backend reads the same variables and must agree on the namespace — -the full table is in [`apps/backend/README.md`](../backend/README.md#connecting-to-a-secured-temporal-cluster). +cluster or Temporal Cloud. Both apps read them through +[`@workflow-builder/temporal-connection`](../../packages/temporal-connection/README.md), so the rules +cannot drift, but each environment must still agree on the namespace — the full table is in +[`apps/backend/README.md`](../backend/README.md#connecting-to-a-secured-temporal-cluster). ## Structure ``` src/ ├── database.ts # Raw SQL for exec events + status updates (no Drizzle — avoids backend schema coupling) -├── env.ts # Centralized env reading, with the defaults documented above +├── env.ts # Env reading with the defaults documented above (TEMPORAL_* come from @workflow-builder/temporal-connection) └── engines/ └── temporal/ ├── worker.ts # Worker bootstrap: executors + store, handed to WorkflowBuilderPlugin @@ -71,7 +73,7 @@ own: one executor per node type and the database as the store port. ## Temporal specifics - **Task queue:** `workflow-execution`, read from `plugin.taskQueue` so the backend and the worker cannot drift apart. Both default to the same constant in the package. -- **Namespace:** `TEMPORAL_NAMESPACE`, default `default`. Unlike the task queue this is _not_ shared through the plugin, so the two apps have to be configured to agree — a mismatch is silent, the worker simply never sees the backend's submissions. +- **Namespace:** `TEMPORAL_NAMESPACE`, default `default`. Unlike the task queue this is _not_ shared through the plugin: both apps read it through `@workflow-builder/temporal-connection`, but each environment has to set the same value — a mismatch is silent, the worker simply never sees the backend's submissions. - **Workflow ID:** `execution-` — deterministic, lets the backend cancel by execution ID. Also owned by the package. - **Activity timeouts:** DB activities get 30s / 5 retries; node activities (may call LLMs) get 10m / 2 retries. Exported as `DEFAULT_DATABASE_ACTIVITY_PROFILE` and `DEFAULT_NODE_ACTIVITY_PROFILE`. - **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior. Of the reference executors, only the AI Agent's `ai_not_configured` is classified (permanent) so far; the rest are still unclassified. diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index 62588ea04..71022e906 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -18,6 +18,7 @@ "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@workflow-builder/execution-core": "workspace:*", + "@workflow-builder/temporal-connection": "workspace:*", "@workflow-builder/types": "workspace:*", "@workflowbuilder/temporal": "workspace:*", "ai": "catalog:", @@ -26,10 +27,7 @@ "tsx": "^4.19.3" }, "devDependencies": { - "@temporalio/client": "catalog:", - "@temporalio/testing": "catalog:", "@types/node": "^22.12.0", - "@workflow-builder/tools": "workspace:*", "vitest": "^3.0.4" } } diff --git a/apps/execution-worker/src/engines/temporal/temporal-connection.test.ts b/apps/execution-worker/src/engines/temporal/temporal-connection.test.ts deleted file mode 100644 index 195c635fa..000000000 --- a/apps/execution-worker/src/engines/temporal/temporal-connection.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Mirrors apps/backend/src/engine/temporal-connection.test.ts. The builder is -// duplicated per app (see the note in temporal-connection.ts), so the tests are too — -// that is what catches the copies drifting apart. -import { describe, expect, it, vi } from 'vitest'; - -import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; - -const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; - -function config(overrides: Partial): TemporalConnectionConfig { - return { ...empty, ...overrides }; -} - -// Keyed by path so a test can tell the CA apart from the client cert. -function fakeReader() { - return vi.fn((path: string) => new TextEncoder().encode(`contents-of:${path}`)); -} - -function bytes(path: string) { - return new TextEncoder().encode(`contents-of:${path}`); -} - -describe('buildTemporalConnectionOptions', () => { - it('stays plaintext when nothing is configured — the local-dev default', () => { - expect(buildTemporalConnectionOptions(empty, fakeReader())).toEqual({}); - }); - - it('enables TLS with the OS trust store on TEMPORAL_TLS=true', () => { - expect(buildTemporalConnectionOptions(config({ tls: 'true' }), fakeReader())).toEqual({ tls: true }); - }); - - it('stays plaintext on an explicit TEMPORAL_TLS=false', () => { - expect(buildTemporalConnectionOptions(config({ tls: 'false' }), fakeReader())).toEqual({}); - }); - - // Mirrors the SDK's own normalizeTlsConfig, which turns TLS on whenever an - // apiKey is present. Temporal Cloud rejects an API key sent in the clear. - it('infers TLS from an API key alone', () => { - expect(buildTemporalConnectionOptions(config({ apiKey: 'tmprl-key' }), fakeReader())).toEqual({ - tls: true, - apiKey: 'tmprl-key', - }); - }); - - it('loads a private CA certificate', () => { - const read = fakeReader(); - - expect(buildTemporalConnectionOptions(config({ caPath: '/certs/ca.pem' }), read)).toEqual({ - tls: { serverRootCACertificate: bytes('/certs/ca.pem') }, - }); - expect(read).toHaveBeenCalledWith('/certs/ca.pem'); - }); - - it('loads a full mTLS pair alongside the CA', () => { - const options = buildTemporalConnectionOptions( - config({ caPath: '/certs/ca.pem', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }), - fakeReader(), - ); - - expect(options).toEqual({ - tls: { - serverRootCACertificate: bytes('/certs/ca.pem'), - clientCertPair: { crt: bytes('/certs/client.pem'), key: bytes('/certs/client.key') }, - }, - }); - }); -}); - -describe('buildTemporalConnectionOptions rejects contradictory config at connect time', () => { - it('refuses half an mTLS pair', () => { - expect(() => buildTemporalConnectionOptions(config({ certPath: '/certs/client.pem' }), fakeReader())).toThrow( - /must be set together/, - ); - expect(() => buildTemporalConnectionOptions(config({ keyPath: '/certs/client.key' }), fakeReader())).toThrow( - /must be set together/, - ); - }); - - it('refuses an API key and a client certificate together', () => { - const both = config({ apiKey: 'k', certPath: '/certs/client.pem', keyPath: '/certs/client.key' }); - - expect(() => buildTemporalConnectionOptions(both, fakeReader())).toThrow(/not both/); - }); - - it('refuses credentials that TEMPORAL_TLS=false would silently discard', () => { - const contradiction = config({ tls: 'false', apiKey: 'k' }); - - expect(() => buildTemporalConnectionOptions(contradiction, fakeReader())).toThrow(/contradicts/); - }); - - it('refuses a TEMPORAL_TLS value that is neither true nor false', () => { - expect(() => buildTemporalConnectionOptions(config({ tls: 'yes' }), fakeReader())).toThrow(/must be 'true'/); - }); - - it('names the variable and the path when a certificate cannot be read', () => { - const explode = vi.fn(() => { - throw new Error('ENOENT'); - }); - - expect(() => buildTemporalConnectionOptions(config({ caPath: '/nope.pem' }), explode)).toThrow( - /TEMPORAL_TLS_CA_PATH \(\/nope\.pem\)/, - ); - }); -}); diff --git a/apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts b/apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts deleted file mode 100644 index e9e04dac7..000000000 --- a/apps/execution-worker/src/engines/temporal/temporal-connection.tls.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Drives the options this builder produces through the worker's native (Rust) transport: -// a Temporal dev server behind a TLS-terminating proxy, with certificates minted for the -// run. The unit tests next door prove the shape of the options; this file proves they connect. -import { Client } from '@temporalio/client'; -import { TestWorkflowEnvironment } from '@temporalio/testing'; -import { NativeConnection, Worker } from '@temporalio/worker'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -import { - type TestPki, - type TestPkiFiles, - createTestPki, - startAuthorizationSink, - startTlsProxy, - writeTestPki, -} from '@workflow-builder/tools/tls-test-harness'; - -import { type TemporalConnectionConfig, buildTemporalConnectionOptions } from './temporal-connection'; - -const NAMESPACE = 'tls-test'; -const TASK_QUEUE = 'tls-probe'; - -const empty: TemporalConnectionConfig = { tls: null, apiKey: null, caPath: null, certPath: null, keyPath: null }; - -function config(overrides: Partial): TemporalConnectionConfig { - return { ...empty, ...overrides }; -} - -function connectVia(address: string, overrides: Partial) { - return NativeConnection.connect({ address, ...buildTemporalConnectionOptions(config(overrides)) }); -} - -type Pki = { pki: TestPki; files: TestPkiFiles }; - -function mint(name: string): Pki { - const pki = createTestPki(name); - return { pki, files: writeTestPki(pki, name) }; -} - -describe('Temporal worker over TLS', () => { - let env: TestWorkflowEnvironment; - // `trusted` is what the server presents and requires; `stranger` is a second, unrelated CA. - let trusted: Pki; - let stranger: Pki; - - beforeAll(async () => { - [env, trusted, stranger] = await Promise.all([ - TestWorkflowEnvironment.createLocal({ server: { extraArgs: ['--namespace', NAMESPACE] } }), - mint('trusted'), - mint('stranger'), - ]); - }, 300_000); - - afterAll(async () => { - await env?.teardown(); - }); - - it('connects through a private CA and executes a workflow in a non-default namespace', async () => { - const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); - try { - const connection = await connectVia(proxy.address, { caPath: trusted.files.ca }); - const worker = await Worker.create({ - connection, - namespace: NAMESPACE, - taskQueue: TASK_QUEUE, - workflowsPath: fileURLToPath(new URL('test-fixtures/tls-probe-workflow.ts', import.meta.url)), - }); - // The client submits over the dev server's own plaintext connection; only the - // worker's polling and completion travel through TLS. - const client = new Client({ connection: env.connection, namespace: NAMESPACE }); - const result = await worker.runUntil( - client.workflow.execute('tlsProbe', { taskQueue: TASK_QUEUE, workflowId: `tls-probe-${Date.now()}` }), - ); - - expect(result).toBe('pong'); - await connection.close(); - expect(proxy.handshakeErrors).toEqual([]); - } finally { - await proxy.close(); - } - }, 120_000); - - it('authenticates with a client certificate when the server requires one', async () => { - const proxy = await startTlsProxy({ - upstream: env.address, - server: trusted.pki.server, - clientCa: trusted.pki.ca.cert, - }); - try { - const connection = await connectVia(proxy.address, { - caPath: trusted.files.ca, - certPath: trusted.files.clientCert, - keyPath: trusted.files.clientKey, - }); - await connection.close(); - expect(proxy.handshakeErrors).toEqual([]); - } finally { - await proxy.close(); - } - }, 60_000); - - it('refuses a server certificate from a CA it does not trust', async () => { - const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); - try { - await expect(connectVia(proxy.address, { caPath: stranger.files.ca })).rejects.toThrow(); - await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); - } finally { - await proxy.close(); - } - }, 60_000); - - it('is refused when its client certificate comes from the wrong CA', async () => { - const proxy = await startTlsProxy({ - upstream: env.address, - server: trusted.pki.server, - clientCa: trusted.pki.ca.cert, - }); - try { - await expect( - connectVia(proxy.address, { - caPath: trusted.files.ca, - certPath: stranger.files.clientCert, - keyPath: stranger.files.clientKey, - }), - ).rejects.toThrow(); - await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); - } finally { - await proxy.close(); - } - }, 60_000); - - it('sends TEMPORAL_API_KEY as a bearer token inside the TLS session', async () => { - const sink = await startAuthorizationSink(trusted.pki.server); - try { - // the sink answers UNAUTHENTICATED on purpose; the header having arrived is the assertion - await expect(connectVia(sink.address, { apiKey: 'synthetic-key', caPath: trusted.files.ca })).rejects.toThrow(); - expect(sink.authorizations).toContain('Bearer synthetic-key'); - } finally { - await sink.close(); - } - }, 60_000); -}); diff --git a/apps/execution-worker/src/engines/temporal/temporal-connection.ts b/apps/execution-worker/src/engines/temporal/temporal-connection.ts deleted file mode 100644 index 94496d2b2..000000000 --- a/apps/execution-worker/src/engines/temporal/temporal-connection.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Turns the TEMPORAL_* env vars into Temporal connection options. -// -// Duplicated by design from apps/backend/src/engine/temporal-connection.ts: the two -// SDKs type their options separately (the client accepts a function for `apiKey`, the -// worker's native connection only a string), so a shared module would have to pick one -// and cast. Keep the two copies in sync. -import type { NativeConnectionOptions } from '@temporalio/worker'; -import { readFileSync } from 'node:fs'; - -export type TemporalConnectionConfig = { - // Raw TEMPORAL_TLS. Tri-state on purpose: unset means "infer from the rest", - // which is not the same as an explicit 'false'. - tls: string | null; - apiKey: string | null; - caPath: string | null; - certPath: string | null; - keyPath: string | null; -}; - -type TemporalConnectionOptions = Pick; - -export function buildTemporalConnectionOptions( - config: TemporalConnectionConfig, - readFile: (path: string) => Uint8Array = readFileSync, -): TemporalConnectionOptions { - const { tls, apiKey, caPath, certPath, keyPath } = config; - - if (tls !== null && tls !== 'true' && tls !== 'false') { - throw new Error(`TEMPORAL_TLS must be 'true' or 'false' (got '${tls}').`); - } - if (Boolean(certPath) !== Boolean(keyPath)) { - throw new Error( - 'TEMPORAL_TLS_CERT_PATH and TEMPORAL_TLS_KEY_PATH must be set together — mTLS needs both halves of the pair.', - ); - } - if (apiKey && certPath) { - throw new Error('Set either TEMPORAL_API_KEY or an mTLS client certificate pair, not both.'); - } - - const hasTlsMaterial = Boolean(apiKey || caPath || certPath); - if (tls === 'false' && hasTlsMaterial) { - throw new Error( - 'TEMPORAL_TLS=false contradicts the TEMPORAL_API_KEY / TEMPORAL_TLS_*_PATH values that are set — remove one side.', - ); - } - - // Material implies TLS, matching what the SDK already does for apiKey. Being - // explicit here keeps the client and the worker in step and makes it testable. - if (tls !== 'true' && !hasTlsMaterial) { - // Plaintext — the local-dev default, and what this worker did before. - return {}; - } - - const certificates = { - ...(caPath ? { serverRootCACertificate: read(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), - ...(certPath && keyPath - ? { - clientCertPair: { - crt: read(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), - key: read(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), - }, - } - : {}), - }; - - return { - // `true` means TLS with the OS trust store — enough for Temporal Cloud. - tls: Object.keys(certificates).length > 0 ? certificates : true, - ...(apiKey ? { apiKey } : {}), - }; -} - -function read(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { - try { - return readFile(path); - } catch (error) { - throw new Error(`Could not read ${variable} (${path}).`, { cause: error }); - } -} diff --git a/apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts b/apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts deleted file mode 100644 index 6710f8974..000000000 --- a/apps/execution-worker/src/engines/temporal/test-fixtures/tls-probe-workflow.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Bundled by path in temporal-connection.tls.test.ts; the real workflows.ts would -// drag the whole plugin in, and the test only needs proof that a task round-trips. -export async function tlsProbe(): Promise { - return 'pong'; -} diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index 118ae7408..bfa37b6de 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -3,6 +3,8 @@ import { WorkflowBuilderPlugin } from '@workflowbuilder/temporal'; import 'dotenv/config'; import { fileURLToPath } from 'node:url'; +import { temporalConfig } from '@workflow-builder/temporal-connection'; + import { database } from '../../database'; import type { AiStudioNode } from '../../domain/ai-studio-nodes'; import { env } from '../../env'; @@ -12,7 +14,6 @@ import { executeTrigger } from '../../executors/trigger'; import { executeVisualize } from '../../executors/visualize'; import { logger } from '../../logger'; import { withPayloadSizeWarning } from '../../store-payload-warning'; -import { buildTemporalConnectionOptions } from './temporal-connection'; const missingAiConfig = (['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] as const).filter((name) => !env[name]); if (missingAiConfig.length > 0) { @@ -43,24 +44,16 @@ const plugin = new WorkflowBuilderPlugin({ // without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS. // Contradictory TEMPORAL_* values throw here, before the worker starts polling. -const connection = await NativeConnection.connect({ - address: env.TEMPORAL_ADDRESS, - ...buildTemporalConnectionOptions({ - tls: env.TEMPORAL_TLS, - apiKey: env.TEMPORAL_API_KEY, - caPath: env.TEMPORAL_TLS_CA_PATH, - certPath: env.TEMPORAL_TLS_CERT_PATH, - keyPath: env.TEMPORAL_TLS_KEY_PATH, - }), -}); +const temporal = temporalConfig(); +const connection = await NativeConnection.connect(temporal.connection); const worker = await Worker.create({ connection, - namespace: env.TEMPORAL_NAMESPACE, + namespace: temporal.namespace, taskQueue: plugin.taskQueue, workflowsPath: fileURLToPath(new URL('workflows.ts', import.meta.url)), plugins: [plugin], }); -logger.info('execution worker started', { taskQueue: plugin.taskQueue, namespace: env.TEMPORAL_NAMESPACE }); +logger.info('execution worker started', { taskQueue: plugin.taskQueue, namespace: temporal.namespace }); await worker.run(); diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index 87df5a83a..780782116 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -12,18 +12,7 @@ function envOptional(name: string): string | null { // bindings; see apps/backend/src/env.ts for the full reason. export const env = { DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), - TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'), - // Must match the backend's, or the worker polls a queue nobody submits to. - // Temporal Cloud spells it `.`. - TEMPORAL_NAMESPACE: envOr('TEMPORAL_NAMESPACE', 'default'), - // Unset means "infer": any of the credentials below turns TLS on. Set it to - // 'true' to require TLS on its own, or 'false' to assert plaintext. - TEMPORAL_TLS: envOptional('TEMPORAL_TLS'), - TEMPORAL_API_KEY: envOptional('TEMPORAL_API_KEY'), - // Paths, read at connect time. CA for a private issuer; the cert/key pair for mTLS. - TEMPORAL_TLS_CA_PATH: envOptional('TEMPORAL_TLS_CA_PATH'), - TEMPORAL_TLS_CERT_PATH: envOptional('TEMPORAL_TLS_CERT_PATH'), - TEMPORAL_TLS_KEY_PATH: envOptional('TEMPORAL_TLS_KEY_PATH'), + // TEMPORAL_*: read at startup by @workflow-builder/temporal-connection // AI Agent nodes need all three. Any missing one: the worker still boots and runs // every non-AI node; AI Agent nodes fail with `ai_not_configured` when reached. // No built-in endpoint or model — nothing in the code points outside the network. diff --git a/apps/tools/README.md b/apps/tools/README.md index ba19e3a94..998f84383 100644 --- a/apps/tools/README.md +++ b/apps/tools/README.md @@ -8,10 +8,6 @@ A collection of scripts and utilities for automating project-specific developmen - **collect-decision-logs**: Compiles a list of `*.decision-log.md` files from the project directory and its subdirectories -### Libraries - -- **tls-test-harness** (`@workflow-builder/tools/tls-test-harness`): throwaway CA + server / client certificates, a TLS-terminating proxy for a plaintext Temporal dev server, and an endpoint that records bearer tokens. Used by the TLS connection tests in `apps/backend` and `apps/execution-worker`; never shipped. - ## Example of usage ```bash diff --git a/apps/tools/package.json b/apps/tools/package.json index 30a0a5d8e..d538b0c1b 100644 --- a/apps/tools/package.json +++ b/apps/tools/package.json @@ -3,21 +3,14 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": { - "./tls-test-harness": "./src/tls-test-harness/index.ts" - }, "scripts": { "collect-decision-logs": "tsx ./src/scripts/collect-decision-logs.ts", "typecheck": "tsc --noEmit", "lint": "eslint", "lint:fix": "eslint --fix" }, - "dependencies": { - "node-forge": "^1.4.0" - }, "devDependencies": { "@types/node": "^22.12.0", - "@types/node-forge": "^1.3.14", "chalk": "^5.4.1", "remeda": "^2.19.2" } diff --git a/apps/tools/src/tls-test-harness/index.ts b/apps/tools/src/tls-test-harness/index.ts deleted file mode 100644 index 822adba64..000000000 --- a/apps/tools/src/tls-test-harness/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Test-only TLS harness for the Temporal connection builders in apps/backend and -// apps/execution-worker: throwaway certificates, a TLS-terminating proxy in front of -// a plaintext dev server, and an endpoint that records bearer tokens. -export { type PemPair, type TestPki, type TestPkiFiles, createTestPki, writeTestPki } from './certificates'; -export { type AuthorizationSink, startAuthorizationSink } from './authorization-sink'; -export { type TlsProxy, type TlsProxyOptions, startTlsProxy } from './tls-proxy'; diff --git a/knip.config.js b/knip.config.js index ad00d1ee7..f11bf3f2c 100644 --- a/knip.config.js +++ b/knip.config.js @@ -29,8 +29,7 @@ export default { ignoreDependencies: ['@phosphor-icons/core', '@svgr/core'], }, 'apps/tools': { - // tls-test-harness is imported by the backend and worker TLS tests via the package exports - entry: ['src/scripts/*.ts', 'src/tls-test-harness/index.ts'], + entry: ['src/scripts/*.ts'], project: 'src/**/*.ts', }, 'packages/types': { @@ -49,13 +48,16 @@ export default { 'packages/execution-core': { entry: ['src/index.ts'], }, + 'packages/temporal-connection': { + // test/fixtures/tls-probe-workflow.ts is handed to Temporal's bundler by path, so nothing imports it + entry: ['src/index.ts', 'test/fixtures/tls-probe-workflow.ts'], + project: ['src/**/*.ts', 'test/**/*.ts'], + // Never imported here, but Temporal's workflow bundler resolves it from this + // workspace while compiling the test fixture. + ignoreDependencies: ['@temporalio/workflow'], + }, 'apps/execution-worker': { - // test-fixtures/tls-probe-workflow.ts is handed to Temporal's bundler by path, so nothing imports it - entry: [ - 'src/engines/temporal/worker.ts', - 'src/engines/temporal/workflows.ts', - 'src/engines/temporal/test-fixtures/tls-probe-workflow.ts', - ], + entry: ['src/engines/temporal/worker.ts', 'src/engines/temporal/workflows.ts'], // @temporalio/workflow is never imported by this app's code, but Temporal's // workflow bundler resolves it from *here* while compiling workflows.ts (the // re-exported runner imports it), so it has to be installed in this workspace. diff --git a/packages/temporal-connection/README.md b/packages/temporal-connection/README.md new file mode 100644 index 000000000..a0b3fedc4 --- /dev/null +++ b/packages/temporal-connection/README.md @@ -0,0 +1,20 @@ +# @workflow-builder/temporal-connection + +Private, source-only. Turns the `TEMPORAL_*` environment variables into everything the apps need to reach Temporal — connection options and the namespace — and holds the one copy of the rules: the defaults, which combinations are contradictory, when TLS is inferred, and how certificate files are read. + +Two consumers hand the result straight to their SDK: `apps/backend/src/engine/index.ts` (`@temporalio/client`) and `apps/execution-worker/src/engines/temporal/worker.ts` (`@temporalio/worker`). Change a rule here and both apps follow; a rule that only one of them should have does not belong here. + +Nothing is validated at import time. `temporalConfig` reads `process.env` (or the environment it is given) when called and throws on a bad combination, so each app calls it where it wants the failure surfaced — the backend in its first-connection factory, the worker before it starts polling. + +```ts +import { temporalConfig } from '@workflow-builder/temporal-connection'; + +const { connection, namespace } = temporalConfig(); +// connection: { address } for plaintext, { address, tls: true } for the OS trust store, +// { address, tls: { serverRootCACertificate, clientCertPair? }, apiKey? } otherwise +// namespace: TEMPORAL_NAMESPACE, 'default' when unset +``` + +Tests: `src/index.test.ts` is the validation matrix. `test/tls.test.ts` drives the built options through a real TLS handshake on both SDK transports (grpc-js and the worker's native core) against a Temporal dev server behind a TLS-terminating proxy (`test/harness/`), with certificates minted per run — private CA, mutual TLS, untrusted server CA, wrong client certificate, an API key inside the TLS session, and work in a non-default namespace. Handing the connection options to both SDKs' connect calls there is the compile-time proof that the contract fits both. + +This module is engine plumbing, not part of the execution model, so it is neither in `execution-core` nor in the published `@workflowbuilder/temporal` API. diff --git a/packages/temporal-connection/eslint.config.mjs b/packages/temporal-connection/eslint.config.mjs new file mode 100644 index 000000000..eee9610de --- /dev/null +++ b/packages/temporal-connection/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from '../../eslint.config.mjs'; diff --git a/packages/temporal-connection/lint-staged.config.mjs b/packages/temporal-connection/lint-staged.config.mjs new file mode 100644 index 000000000..63809e0a3 --- /dev/null +++ b/packages/temporal-connection/lint-staged.config.mjs @@ -0,0 +1 @@ +export { default } from '../../lint-staged.config.mjs'; diff --git a/packages/temporal-connection/package.json b/packages/temporal-connection/package.json new file mode 100644 index 000000000..b057bafe5 --- /dev/null +++ b/packages/temporal-connection/package.json @@ -0,0 +1,26 @@ +{ + "name": "@workflow-builder/temporal-connection", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@temporalio/client": "catalog:", + "@temporalio/testing": "catalog:", + "@temporalio/worker": "catalog:", + "@temporalio/workflow": "catalog:", + "@types/node": "^22.12.0", + "@types/node-forge": "^1.3.14", + "node-forge": "^1.4.0", + "vitest": "^3.0.4" + } +} diff --git a/packages/temporal-connection/src/index.test.ts b/packages/temporal-connection/src/index.test.ts new file mode 100644 index 000000000..d35c9a7c7 --- /dev/null +++ b/packages/temporal-connection/src/index.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { temporalConfig } from './index'; + +// Keyed by path so a test can tell the CA apart from the client cert. +function fakeReader() { + return vi.fn((path: string) => new TextEncoder().encode(`contents-of:${path}`)); +} + +function bytes(path: string) { + return new TextEncoder().encode(`contents-of:${path}`); +} + +const ADDRESS = 'temporal.example:7233'; + +// TLS / API-key cases are about everything but the address, so they pin it. +function tlsOptions(env: NodeJS.ProcessEnv, readFile = fakeReader()) { + return temporalConfig({ TEMPORAL_ADDRESS: ADDRESS, ...env }, readFile).connection; +} + +describe('temporalConfig', () => { + it('defaults to the local docker stack on the default namespace', () => { + expect(temporalConfig({}, fakeReader())).toEqual({ + connection: { address: '127.0.0.1:7233' }, + namespace: 'default', + }); + }); + + it('reads the address and namespace', () => { + const env = { TEMPORAL_ADDRESS: 'ns.acct.tmprl.cloud:7233', TEMPORAL_NAMESPACE: 'ns.acct' }; + + expect(temporalConfig(env, fakeReader())).toEqual({ + connection: { address: 'ns.acct.tmprl.cloud:7233' }, + namespace: 'ns.acct', + }); + }); + + it('reads process.env when no environment is given', () => { + vi.stubEnv('TEMPORAL_NAMESPACE', 'from-process-env'); + try { + expect(temporalConfig().namespace).toBe('from-process-env'); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('temporalConfig().connection TLS', () => { + it('stays plaintext when nothing is configured — the local-dev default', () => { + expect(tlsOptions({})).toEqual({ address: ADDRESS }); + }); + + // compose passes absent optionals through as `${VAR:-}`, so '' must not count as configured + it('treats an empty string like an unset variable', () => { + const env = { TEMPORAL_TLS: '', TEMPORAL_API_KEY: '', TEMPORAL_TLS_CA_PATH: '' }; + + expect(tlsOptions(env)).toEqual({ address: ADDRESS }); + }); + + it('enables TLS with the OS trust store on TEMPORAL_TLS=true', () => { + expect(tlsOptions({ TEMPORAL_TLS: 'true' })).toEqual({ address: ADDRESS, tls: true }); + }); + + it('stays plaintext on an explicit TEMPORAL_TLS=false', () => { + expect(tlsOptions({ TEMPORAL_TLS: 'false' })).toEqual({ address: ADDRESS }); + }); + + // Mirrors the SDK's own normalizeTlsConfig, which turns TLS on whenever an + // apiKey is present. Temporal Cloud rejects an API key sent in the clear. + it('infers TLS from an API key alone', () => { + expect(tlsOptions({ TEMPORAL_API_KEY: 'tmprl-key' })).toEqual({ + address: ADDRESS, + tls: true, + apiKey: 'tmprl-key', + }); + }); + + it('loads a private CA certificate', () => { + const read = fakeReader(); + + expect(tlsOptions({ TEMPORAL_TLS_CA_PATH: '/certs/ca.pem' }, read)).toEqual({ + address: ADDRESS, + tls: { serverRootCACertificate: bytes('/certs/ca.pem') }, + }); + expect(read).toHaveBeenCalledWith('/certs/ca.pem'); + }); + + it('loads a full mTLS pair alongside the CA', () => { + const env = { + TEMPORAL_TLS_CA_PATH: '/certs/ca.pem', + TEMPORAL_TLS_CERT_PATH: '/certs/client.pem', + TEMPORAL_TLS_KEY_PATH: '/certs/client.key', + }; + + expect(tlsOptions(env)).toEqual({ + address: ADDRESS, + tls: { + serverRootCACertificate: bytes('/certs/ca.pem'), + clientCertPair: { crt: bytes('/certs/client.pem'), key: bytes('/certs/client.key') }, + }, + }); + }); +}); + +describe('temporalConfig rejects contradictory TLS config at connect time', () => { + it('refuses half an mTLS pair', () => { + expect(() => tlsOptions({ TEMPORAL_TLS_CERT_PATH: '/certs/client.pem' })).toThrow(/must be set together/); + expect(() => tlsOptions({ TEMPORAL_TLS_KEY_PATH: '/certs/client.key' })).toThrow(/must be set together/); + }); + + it('refuses an API key and a client certificate together', () => { + const both = { + TEMPORAL_API_KEY: 'k', + TEMPORAL_TLS_CERT_PATH: '/certs/client.pem', + TEMPORAL_TLS_KEY_PATH: '/certs/client.key', + }; + + expect(() => tlsOptions(both)).toThrow(/not both/); + }); + + it('refuses credentials that TEMPORAL_TLS=false would silently discard', () => { + const contradiction = { TEMPORAL_TLS: 'false', TEMPORAL_API_KEY: 'k' }; + + expect(() => tlsOptions(contradiction)).toThrow(/contradicts/); + }); + + it('refuses a TEMPORAL_TLS value that is neither true nor false', () => { + expect(() => tlsOptions({ TEMPORAL_TLS: 'yes' })).toThrow(/must be 'true'/); + }); + + it('names the variable and the path when a certificate cannot be read', () => { + const explode = vi.fn(() => { + throw new Error('ENOENT'); + }); + + expect(() => tlsOptions({ TEMPORAL_TLS_CA_PATH: '/nope.pem' }, explode)).toThrow( + /TEMPORAL_TLS_CA_PATH \(\/nope\.pem\)/, + ); + }); +}); diff --git a/packages/temporal-connection/src/index.ts b/packages/temporal-connection/src/index.ts new file mode 100644 index 000000000..25781a33d --- /dev/null +++ b/packages/temporal-connection/src/index.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs'; + +export type TemporalTlsOptions = { + serverRootCACertificate?: Uint8Array; + clientCertPair?: { crt: Uint8Array; key: Uint8Array }; +}; + +// The subset both SDKs accept as-is: the client also takes an apiKey function and +// tls: false | null, neither of which this module ever produces. +export type TemporalConnectionOptions = { + address: string; + tls?: true | TemporalTlsOptions; + apiKey?: string; +}; + +export type TemporalConfig = { + connection: TemporalConnectionOptions; + // Not a connection option — it goes to the Client and the Worker — but it must + // match between the two, so it is read here alongside the rest. + namespace: string; +}; + +// 127.0.0.1, not `localhost`: the local docker stack binds loopback IPv4 only, and +// some Node setups resolve `localhost` to ::1 first (see apps/backend/src/env.ts). +const DEFAULT_ADDRESS = '127.0.0.1:7233'; +// Temporal Cloud spells it `.`. +const DEFAULT_NAMESPACE = 'default'; + +type Config = { + // Raw TEMPORAL_TLS. Tri-state on purpose: unset means "infer from the rest", + // which is not the same as an explicit 'false'. + tls: string | null; + apiKey: string | null; + caPath: string | null; + certPath: string | null; + keyPath: string | null; +}; + +export function temporalConfig( + env: NodeJS.ProcessEnv = process.env, + readFile: (path: string) => Uint8Array = readFileSync, +): TemporalConfig { + return { + connection: { address: env['TEMPORAL_ADDRESS'] || DEFAULT_ADDRESS, ...connectionOptions(env, readFile) }, + namespace: env['TEMPORAL_NAMESPACE'] || DEFAULT_NAMESPACE, + }; +} + +function connectionOptions( + env: NodeJS.ProcessEnv, + readFile: (path: string) => Uint8Array, +): Omit { + const { tls, apiKey, caPath, certPath, keyPath } = read(env); + + if (tls !== null && tls !== 'true' && tls !== 'false') { + throw new Error(`TEMPORAL_TLS must be 'true' or 'false' (got '${tls}').`); + } + if (Boolean(certPath) !== Boolean(keyPath)) { + throw new Error( + 'TEMPORAL_TLS_CERT_PATH and TEMPORAL_TLS_KEY_PATH must be set together — mTLS needs both halves of the pair.', + ); + } + if (apiKey && certPath) { + throw new Error('Set either TEMPORAL_API_KEY or an mTLS client certificate pair, not both.'); + } + + const hasTlsMaterial = Boolean(apiKey || caPath || certPath); + if (tls === 'false' && hasTlsMaterial) { + throw new Error( + 'TEMPORAL_TLS=false contradicts the TEMPORAL_API_KEY / TEMPORAL_TLS_*_PATH values that are set — remove one side.', + ); + } + + // Material implies TLS, matching what the SDKs already do for apiKey. Being + // explicit here keeps the client and the worker in step and makes it testable. + if (tls !== 'true' && !hasTlsMaterial) { + // Plaintext — the local-dev default. + return {}; + } + + const certificates: TemporalTlsOptions = { + ...(caPath ? { serverRootCACertificate: readPem(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), + ...(certPath && keyPath + ? { + clientCertPair: { + crt: readPem(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), + key: readPem(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), + }, + } + : {}), + }; + + return { + // `true` means TLS with the OS trust store — enough for Temporal Cloud. + tls: Object.keys(certificates).length > 0 ? certificates : true, + ...(apiKey ? { apiKey } : {}), + }; +} + +function read(env: NodeJS.ProcessEnv): Config { + // Empty string counts as unset: compose passes absent optionals through as + // `${VAR:-}`, and a bare `?? null` would read '' as a configured value. + const optional = (name: string) => env[name] || null; + return { + tls: optional('TEMPORAL_TLS'), + apiKey: optional('TEMPORAL_API_KEY'), + caPath: optional('TEMPORAL_TLS_CA_PATH'), + certPath: optional('TEMPORAL_TLS_CERT_PATH'), + keyPath: optional('TEMPORAL_TLS_KEY_PATH'), + }; +} + +function readPem(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { + try { + return readFile(path); + } catch (error) { + throw new Error(`Could not read ${variable} (${path}).`, { cause: error }); + } +} diff --git a/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts b/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts new file mode 100644 index 000000000..451f4e2ea --- /dev/null +++ b/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts @@ -0,0 +1,5 @@ +// Handed to Temporal's bundler by path from tls.test.ts. All the test needs is proof +// that a task round-trips through the worker's TLS connection. +export async function tlsProbe(): Promise { + return 'pong'; +} diff --git a/apps/tools/src/tls-test-harness/authorization-sink.ts b/packages/temporal-connection/test/harness/authorization-sink.ts similarity index 98% rename from apps/tools/src/tls-test-harness/authorization-sink.ts rename to packages/temporal-connection/test/harness/authorization-sink.ts index 3629b381f..902058c61 100644 --- a/apps/tools/src/tls-test-harness/authorization-sink.ts +++ b/packages/temporal-connection/test/harness/authorization-sink.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net'; import type { PemPair } from './certificates'; -export type AuthorizationSink = { +type AuthorizationSink = { address: string; /** The `authorization` header of every gRPC call received, in order. */ authorizations: string[]; diff --git a/apps/tools/src/tls-test-harness/certificates.ts b/packages/temporal-connection/test/harness/certificates.ts similarity index 100% rename from apps/tools/src/tls-test-harness/certificates.ts rename to packages/temporal-connection/test/harness/certificates.ts diff --git a/packages/temporal-connection/test/harness/index.ts b/packages/temporal-connection/test/harness/index.ts new file mode 100644 index 000000000..5ed146f47 --- /dev/null +++ b/packages/temporal-connection/test/harness/index.ts @@ -0,0 +1,5 @@ +// Harness for tls.test.ts: throwaway certificates, a TLS-terminating proxy in front +// of a plaintext dev server, and an endpoint that records bearer tokens. +export { type TestPki, type TestPkiFiles, createTestPki, writeTestPki } from './certificates'; +export { startAuthorizationSink } from './authorization-sink'; +export { startTlsProxy } from './tls-proxy'; diff --git a/apps/tools/src/tls-test-harness/tls-proxy.ts b/packages/temporal-connection/test/harness/tls-proxy.ts similarity index 97% rename from apps/tools/src/tls-test-harness/tls-proxy.ts rename to packages/temporal-connection/test/harness/tls-proxy.ts index a23e7fc5d..6343f54e3 100644 --- a/apps/tools/src/tls-test-harness/tls-proxy.ts +++ b/packages/temporal-connection/test/harness/tls-proxy.ts @@ -3,7 +3,7 @@ import { type TlsOptions, createServer } from 'node:tls'; import type { PemPair } from './certificates'; -export type TlsProxy = { +type TlsProxy = { /** host:port a Temporal client can dial; the hostname is covered by the server certificate's SAN. */ address: string; /** One entry per failed handshake, whichever side aborted it. */ @@ -11,7 +11,7 @@ export type TlsProxy = { close: () => Promise; }; -export type TlsProxyOptions = { +type TlsProxyOptions = { /** host:port of the plaintext Temporal server behind the proxy. */ upstream: string; server: PemPair; diff --git a/packages/temporal-connection/test/tls.test.ts b/packages/temporal-connection/test/tls.test.ts new file mode 100644 index 000000000..f4e2b1530 --- /dev/null +++ b/packages/temporal-connection/test/tls.test.ts @@ -0,0 +1,206 @@ +// Drives the options this package builds through a real TLS handshake on both SDK +// transports: grpc-js in @temporalio/client and the Rust core in @temporalio/worker. +// A Temporal dev server sits behind a TLS-terminating proxy; certificates are minted +// per run. The unit tests prove the shape of the options; this file proves they connect. +import { Client, Connection } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { temporalConfig } from '../src/index'; +import { + type TestPki, + type TestPkiFiles, + createTestPki, + startAuthorizationSink, + startTlsProxy, + writeTestPki, +} from './harness'; + +const NAMESPACE = 'tls-test'; +const TASK_QUEUE = 'tls-probe'; + +type Pki = { pki: TestPki; files: TestPkiFiles }; + +function mint(name: string): Pki { + const pki = createTestPki(name); + return { pki, files: writeTestPki(pki, name) }; +} + +type Transport = { + name: string; + connect: (address: string, env: NodeJS.ProcessEnv) => Promise<{ close(): Promise }>; +}; + +// Handing the built options to each SDK's own connect call is also the compile-time +// proof that the shared contract is assignable to both option types without a cast. +function connectClient(address: string, env: NodeJS.ProcessEnv) { + const { connection } = temporalConfig({ TEMPORAL_ADDRESS: address, ...env }); + return Connection.connect({ connectTimeout: '3s', ...connection }); +} + +function connectWorker(address: string, env: NodeJS.ProcessEnv) { + return NativeConnection.connect(temporalConfig({ TEMPORAL_ADDRESS: address, ...env }).connection); +} + +const transports: Transport[] = [ + { name: '@temporalio/client (grpc-js)', connect: connectClient }, + { name: '@temporalio/worker (native core)', connect: connectWorker }, +]; + +let env: TestWorkflowEnvironment; +// `trusted` is what the server presents and requires; `stranger` is a second, unrelated CA. +let trusted: Pki; +let stranger: Pki; + +beforeAll(async () => { + [env, trusted, stranger] = await Promise.all([ + TestWorkflowEnvironment.createLocal({ server: { extraArgs: ['--namespace', NAMESPACE] } }), + mint('trusted'), + mint('stranger'), + ]); +}, 300_000); + +afterAll(async () => { + await env?.teardown(); +}); + +describe.each(transports)('$name over TLS', ({ connect }) => { + it('connects through a private CA', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const connection = await connect(proxy.address, { TEMPORAL_TLS_CA_PATH: trusted.files.ca }); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('authenticates with a client certificate when the server requires one', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + const connection = await connect(proxy.address, { + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + TEMPORAL_TLS_CERT_PATH: trusted.files.clientCert, + TEMPORAL_TLS_KEY_PATH: trusted.files.clientKey, + }); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('refuses a server certificate from a CA it does not trust', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + await expect(connect(proxy.address, { TEMPORAL_TLS_CA_PATH: stranger.files.ca })).rejects.toThrow(); + // the proxy records the failed handshake asynchronously, after the client has given up + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('is refused when its client certificate comes from the wrong CA', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + await expect( + connect(proxy.address, { + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + TEMPORAL_TLS_CERT_PATH: stranger.files.clientCert, + TEMPORAL_TLS_KEY_PATH: stranger.files.clientKey, + }), + ).rejects.toThrow(); + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('sends TEMPORAL_API_KEY as a bearer token inside the TLS session', async () => { + const sink = await startAuthorizationSink(trusted.pki.server); + try { + // the sink answers UNAUTHENTICATED on purpose; the header having arrived is the assertion + await expect( + connect(sink.address, { TEMPORAL_API_KEY: 'synthetic-key', TEMPORAL_TLS_CA_PATH: trusted.files.ca }), + ).rejects.toThrow(); + expect(sink.authorizations).toContain('Bearer synthetic-key'); + } finally { + await sink.close(); + } + }, 60_000); +}); + +describe('work in a non-default namespace over a private CA', () => { + it('a client starts a workflow that lands in the configured namespace', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const config = temporalConfig({ + TEMPORAL_ADDRESS: proxy.address, + TEMPORAL_NAMESPACE: NAMESPACE, + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + }); + const connection = await Connection.connect(config.connection); + const client = new Client({ connection, namespace: config.namespace }); + const handle = await client.workflow.start('tls-probe', { + taskQueue: TASK_QUEUE, + workflowId: `tls-probe-client-${Date.now()}`, + }); + + // Read back over the dev server's own plaintext connection, so the assertion + // does not depend on the connection under test. + const inNamespace = new Client({ connection: env.connection, namespace: NAMESPACE }); + await expect(inNamespace.workflow.getHandle(handle.workflowId).describe()).resolves.toMatchObject({ + status: { name: 'RUNNING' }, + }); + await expect(env.client.workflow.getHandle(handle.workflowId).describe()).rejects.toThrow(); + + await handle.terminate('tls test done'); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('a worker polls the configured namespace and completes a workflow', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const config = temporalConfig({ + TEMPORAL_ADDRESS: proxy.address, + TEMPORAL_NAMESPACE: NAMESPACE, + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + }); + const connection = await NativeConnection.connect(config.connection); + const worker = await Worker.create({ + connection, + namespace: config.namespace, + taskQueue: TASK_QUEUE, + workflowsPath: fileURLToPath(new URL('fixtures/tls-probe-workflow.ts', import.meta.url)), + }); + // The client submits over the dev server's own plaintext connection; only the + // worker's polling and completion travel through TLS. + const client = new Client({ connection: env.connection, namespace: NAMESPACE }); + const result = await worker.runUntil( + client.workflow.execute('tlsProbe', { taskQueue: TASK_QUEUE, workflowId: `tls-probe-worker-${Date.now()}` }), + ); + + expect(result).toBe('pong'); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 120_000); +}); diff --git a/packages/temporal-connection/tsconfig.json b/packages/temporal-connection/tsconfig.json new file mode 100644 index 000000000..c93019dec --- /dev/null +++ b/packages/temporal-connection/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022"], + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90b4dcea8..944b715d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@workflow-builder/execution-core': specifier: workspace:* version: link:../../packages/execution-core + '@workflow-builder/temporal-connection': + specifier: workspace:* + version: link:../../packages/temporal-connection '@workflow-builder/types': specifier: workspace:* version: link:../../packages/types @@ -253,15 +256,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: - '@temporalio/testing': - specifier: 'catalog:' - version: 1.23.0 '@types/node': specifier: ^22.12.0 version: 22.12.0 - '@workflow-builder/tools': - specifier: workspace:* - version: link:../tools drizzle-kit: specifier: ^0.31.0 version: 0.31.10 @@ -462,6 +459,9 @@ importers: '@workflow-builder/execution-core': specifier: workspace:* version: link:../../packages/execution-core + '@workflow-builder/temporal-connection': + specifier: workspace:* + version: link:../../packages/temporal-connection '@workflow-builder/types': specifier: workspace:* version: link:../../packages/types @@ -481,18 +481,9 @@ importers: specifier: ^4.19.3 version: 4.21.0 devDependencies: - '@temporalio/client': - specifier: 'catalog:' - version: 1.23.0 - '@temporalio/testing': - specifier: 'catalog:' - version: 1.23.0(tslib@2.8.1) '@types/node': specifier: ^22.12.0 version: 22.12.0 - '@workflow-builder/tools': - specifier: workspace:* - version: link:../tools vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) @@ -520,17 +511,10 @@ importers: version: 19.1.0 apps/tools: - dependencies: - node-forge: - specifier: ^1.4.0 - version: 1.4.0 devDependencies: '@types/node': specifier: ^22.12.0 version: 22.12.0 - '@types/node-forge': - specifier: ^1.3.14 - version: 1.3.14 chalk: specifier: ^5.4.1 version: 5.4.1 @@ -703,6 +687,33 @@ importers: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + packages/temporal-connection: + devDependencies: + '@temporalio/client': + specifier: 'catalog:' + version: 1.23.0 + '@temporalio/testing': + specifier: 'catalog:' + version: 1.23.0(tslib@2.8.1) + '@temporalio/worker': + specifier: 'catalog:' + version: 1.23.0(tslib@2.8.1) + '@temporalio/workflow': + specifier: 'catalog:' + version: 1.23.0 + '@types/node': + specifier: ^22.12.0 + version: 22.12.0 + '@types/node-forge': + specifier: ^1.3.14 + version: 1.3.14 + node-forge: + specifier: ^1.4.0 + version: 1.4.0 + vitest: + specifier: ^3.0.4 + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + packages/tokens: devDependencies: '@tokens-studio/sd-transforms': @@ -7436,10 +7447,6 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} - engines: {node: '>=6'} - tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -10540,31 +10547,6 @@ snapshots: long: 5.3.2 protobufjs: 8.8.0 - '@temporalio/testing@1.23.0': - dependencies: - '@temporalio/activity': 1.23.0 - '@temporalio/client': 1.23.0 - '@temporalio/common': 1.23.0 - '@temporalio/core-bridge': 1.23.0 - '@temporalio/proto': 1.23.0 - '@temporalio/worker': 1.23.0 - '@temporalio/workflow': 1.23.0 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/css' - - '@swc/helpers' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - tslib - - uglify-js - - webpack-cli - '@temporalio/testing@1.23.0(esbuild@0.27.3)(postcss@8.5.6)(tslib@2.8.1)': dependencies: '@temporalio/activity': 1.23.0 @@ -10615,44 +10597,6 @@ snapshots: - uglify-js - webpack-cli - '@temporalio/worker@1.23.0': - dependencies: - '@grpc/grpc-js': 1.14.3 - '@swc/core': 1.15.26 - '@temporalio/activity': 1.23.0 - '@temporalio/client': 1.23.0 - '@temporalio/common': 1.23.0 - '@temporalio/core-bridge': 1.23.0 - '@temporalio/nexus': 1.23.0 - '@temporalio/proto': 1.23.0 - '@temporalio/workflow': 1.23.0 - heap-js: 2.7.1 - memfs: 4.57.2(tslib@2.8.1) - nexus-rpc: 0.0.3 - protobufjs: 8.8.0 - rxjs: 7.8.1 - source-map: 0.7.6 - source-map-loader: 5.0.0(webpack@5.110.1(@swc/core@1.15.26)) - supports-color: 8.1.1 - swc-loader: 0.2.7(@swc/core@1.15.26)(webpack@5.110.1(@swc/core@1.15.26)) - unionfs: 4.6.0 - webpack: 5.110.1(@swc/core@1.15.26) - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/css' - - '@swc/helpers' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - tslib - - uglify-js - - webpack-cli - '@temporalio/worker@1.23.0(esbuild@0.27.3)(postcss@8.5.6)(tslib@2.8.1)': dependencies: '@grpc/grpc-js': 1.14.3 @@ -16389,8 +16333,6 @@ snapshots: tailwind-merge@3.5.0: {} - tapable@2.3.2: {} - tapable@2.3.3: {} tar@7.5.11: @@ -17099,7 +17041,7 @@ snapshots: minimizer-webpack-plugin: 5.8.0(@swc/core@1.15.26)(webpack@5.110.1(@swc/core@1.15.26)) neo-async: 2.6.2 schema-utils: 4.3.3 - tapable: 2.3.2 + tapable: 2.3.3 watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: @@ -17134,7 +17076,7 @@ snapshots: minimizer-webpack-plugin: 5.8.0(@swc/core@1.15.26)(esbuild@0.27.3)(postcss@8.5.6)(webpack@5.110.1(@swc/core@1.15.26)(esbuild@0.27.3)(postcss@8.5.6)) neo-async: 2.6.2 schema-utils: 4.3.3 - tapable: 2.3.2 + tapable: 2.3.3 watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: From 0b21addbe039943533ad2449f2a8dbe5f8b3302b Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 16:13:45 +0200 Subject: [PATCH 23/27] refactor(ai-config): share the AI_* contract, keep the runtime reactions apart The rule that AI_API_KEY, AI_BASE_URL and AI_MODEL must be set together lived three times: the backend's adapt route, the worker's executor factory and its startup warning. Both apps also normalised empty values on their own. Add a private source-only workspace, @workflow-builder/ai-config, whose aiConfig() returns a complete config or the names of the missing variables, and make all three sites read it. What each app does when AI is unavailable is unchanged and stays in the app: the backend answers 501 after authorization and the guard, the worker boots and fails an AI Agent node with permanent ai_not_configured only when a run reaches it. The package README is the canonical description of the contract; both app READMEs and .env.example files point at it. The first-run guides named a template that does not exist and invited Play before the LLM section; they now say every bundled template has AI Agent nodes and what to expect without an LLM. --- .github/workflows/pr-check.yml | 6 +- CLAUDE.md | 3 + README.md | 2 +- apps/backend/.env.example | 6 +- apps/backend/README.md | 8 ++- apps/backend/package.json | 1 + apps/backend/src/env.test.ts | 43 +----------- apps/backend/src/env.ts | 16 +---- apps/backend/src/routes/visualize.ts | 9 ++- .../quick-start/standalone-app.mdx | 2 +- apps/execution-worker/.env.example | 1 + apps/execution-worker/README.md | 9 +-- apps/execution-worker/package.json | 1 + .../src/engines/temporal/worker.ts | 11 ++-- apps/execution-worker/src/env.test.ts | 46 +++---------- apps/execution-worker/src/env.ts | 19 ++---- .../src/executors/ai-agent.test.ts | 9 +-- .../src/executors/ai-agent.ts | 20 +++--- knip.config.js | 3 + packages/ai-config/README.md | 36 ++++++++++ packages/ai-config/eslint.config.mjs | 1 + packages/ai-config/lint-staged.config.mjs | 1 + packages/ai-config/package.json | 20 ++++++ packages/ai-config/src/index.test.ts | 65 +++++++++++++++++++ packages/ai-config/src/index.ts | 24 +++++++ packages/ai-config/tsconfig.json | 8 +++ pnpm-lock.yaml | 15 +++++ 27 files changed, 239 insertions(+), 146 deletions(-) create mode 100644 packages/ai-config/README.md create mode 100644 packages/ai-config/eslint.config.mjs create mode 100644 packages/ai-config/lint-staged.config.mjs create mode 100644 packages/ai-config/package.json create mode 100644 packages/ai-config/src/index.test.ts create mode 100644 packages/ai-config/src/index.ts create mode 100644 packages/ai-config/tsconfig.json diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 10a9d4f71..7f2cdfa77 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -245,10 +245,10 @@ jobs: run: pnpm install --frozen-lockfile - name: Lint - run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker lint + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker lint - name: Typecheck - run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker typecheck + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker typecheck - name: Test - run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker test + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker test diff --git a/CLAUDE.md b/CLAUDE.md index eac4d5ac1..5af220bce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,7 @@ apps/ icons/ - Icon generation pipeline tools/ - @workflow-builder/tools workspace (decision-log collector, lint-staged config) packages/ + ai-config/ - Private, source-only: the AI_API_KEY / AI_BASE_URL / AI_MODEL contract, one copy shared by backend and worker sdk/ - @workflowbuilder/sdk public package (WorkflowBuilder compound component, plugin API, components) ui/ - @workflowbuilder/ui published component library (Base UI), consumed by sdk/demo/ai-studio tokens/ - @workflowbuilder/ui-tokens private design-token build (style-dictionary), feeds packages/ui @@ -80,6 +81,7 @@ Each workspace has its own context. Read the relevant file before extending a wo | `packages/sdk` | `packages/sdk/README.md` | | `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) | | `packages/tokens` | `packages/tokens/README.md` | +| `packages/ai-config` | `packages/ai-config/README.md` | | `packages/execution-core` | `packages/execution-core/README.md` | | `packages/temporal` | `packages/temporal/README.md` | | `packages/temporal-connection` | `packages/temporal-connection/README.md` | @@ -91,6 +93,7 @@ Each workspace has its own context. Read the relevant file before extending a wo ## Types & Aliases Shared types: `packages/types/` (imported as `@workflow-builder/types/*`). +AI configuration contract: `packages/ai-config/` (imported as `@workflow-builder/ai-config`; `aiConfig()` tells backend and worker whether the LLM is configured and what is missing). Temporal connection config: `packages/temporal-connection/` (imported as `@workflow-builder/temporal-connection`; `temporalConfig()` gives backend and worker their connect options and namespace). Icons: `apps/icons/` (imported as `@workflow-builder/icons`). SDK: `packages/sdk/` (imported as `@workflowbuilder/sdk`). diff --git a/README.md b/README.md index 99f449c9a..991e1f298 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Temporal ready [ai-studio] ➜ Local: http://127.0.0.1:4201/ ``` -Open `http://localhost:4201`. Pick the "Sales Inquiry" template, click Play. The Temporal UI at `http://localhost:8233` shows the running execution. +Open `http://localhost:4201`. Every bundled template contains AI Agent nodes, so either connect an LLM first (next section) or expect the run to stop at its first AI Agent node with `ai_not_configured` while the Trigger, Decision and Visualize nodes before it run. Pick a template, click Play. The Temporal UI at `http://localhost:8233` shows the running execution. To stop: `Ctrl+C`, then `pnpm infra:down`. diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 1af756ffe..ab1034d78 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -37,9 +37,9 @@ WB_AUTH_PORT=allow-all # valid Turnstile token sent by the frontend as the cf-turnstile-token header. TURNSTILE_SECRET_KEY= # API key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt). -# Optional: leave empty to disable AI adapt (the endpoint returns 501). The -# execution worker keeps its own key for running workflows. OpenRouter keys look -# like sk-or-v1-... +# Optional: leave empty to disable AI adapt (the endpoint returns 501). The three +# AI_* variables are all-or-nothing (see packages/ai-config/README.md). The +# execution worker reads its own copy of them. OpenRouter keys look like sk-or-v1-... AI_API_KEY= # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own # network. Must be the base URL, without a trailing /chat/completions. diff --git a/apps/backend/README.md b/apps/backend/README.md index 311ea1da8..da1eab64b 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -56,9 +56,11 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 ``` -Both also read `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` — all optional, and each side degrades -on its own when any of them is missing: the backend's AI adapt endpoint returns 501, and the worker -runs everything except AI Agent nodes. See [`apps/execution-worker/README.md`](../execution-worker/README.md). +Both also read `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` — all three or none, through +[`@workflow-builder/ai-config`](../../packages/ai-config/README.md), which is the canonical description +of that contract. Each side degrades on its own when they are missing: the backend's AI adapt endpoint +returns 501, and the worker runs everything except AI Agent nodes. See +[`apps/execution-worker/README.md`](../execution-worker/README.md). ### Connecting to a secured Temporal cluster diff --git a/apps/backend/package.json b/apps/backend/package.json index 4e70bc611..3fbcf3545 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -20,6 +20,7 @@ "@ai-sdk/openai-compatible": "catalog:", "@hono/node-server": "^1.14.0", "@temporalio/client": "catalog:", + "@workflow-builder/ai-config": "workspace:*", "@workflow-builder/execution-core": "workspace:*", "@workflow-builder/temporal-connection": "workspace:*", "@workflow-builder/types": "workspace:*", diff --git a/apps/backend/src/env.test.ts b/apps/backend/src/env.test.ts index a8d8131de..348d84bdc 100644 --- a/apps/backend/src/env.test.ts +++ b/apps/backend/src/env.test.ts @@ -28,49 +28,10 @@ afterEach(() => { describe('loadEnv', () => { it('ignores variables inherited from the runner', async () => { - vi.stubEnv('AI_BASE_URL', 'http://ambient.example/v1'); + vi.stubEnv('TURNSTILE_SECRET_KEY', 'ambient-secret'); const env = await loadEnv({}); - expect(env.AI_BASE_URL).toBeNull(); - }); -}); - -describe('AI_API_KEY', () => { - it('reads AI_API_KEY', async () => { - const env = await loadEnv({ AI_API_KEY: 'key' }); - - expect(env.AI_API_KEY).toBe('key'); - }); - - it('reads an empty value as unset', async () => { - const env = await loadEnv({ AI_API_KEY: '' }); - - expect(env.AI_API_KEY).toBeNull(); - }); - - // The alias was dropped rather than scoped: a provider-named key that silently - // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are - // no external deployments to keep working. Rename the variable in .env instead. - it('does not read the retired OPENROUTER_API_KEY name', async () => { - const env = await loadEnv({ OPENROUTER_API_KEY: 'old-key' }); - - expect(env.AI_API_KEY).toBeNull(); - }); -}); - -describe('AI_BASE_URL and AI_MODEL', () => { - // No built-in endpoint or model: the OpenRouter values live in .env.example only. - it('are null when unset', async () => { - const env = await loadEnv({}); - - expect(env.AI_BASE_URL).toBeNull(); - expect(env.AI_MODEL).toBeNull(); - }); - - it('points at any OpenAI-compatible endpoint', async () => { - const env = await loadEnv({ AI_BASE_URL: 'http://vllm.internal:8000/v1' }); - - expect(env.AI_BASE_URL).toBe('http://vllm.internal:8000/v1'); + expect(env.TURNSTILE_SECRET_KEY).toBeNull(); }); }); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index c9410648c..b36d8f5ce 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -2,12 +2,6 @@ function envOr(name: string, defaultValue: string): string { return process.env[name] ?? defaultValue; } -// Empty string counts as unset: compose passes absent optionals through as -// `${VAR:-}`, and a bare `?? null` would read '' as a configured value. -function envOptional(name: string): string | null { - return process.env[name] || null; -} - // 127.0.0.1 (not `localhost`) matches the loopback-only docker bindings in // apps/backend/docker-compose.yml — see local-dev-binding.decision-log.md // for that decision. On some Windows / Node configs `localhost` resolves to @@ -17,18 +11,12 @@ export const env = { PORT: Number(envOr('PORT', '3001')), HOST: envOr('HOST', '127.0.0.1'), DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), - // TEMPORAL_*: read at connect time by @workflow-builder/temporal-connection + // TEMPORAL_*: read at connect time by @workflow-builder/temporal-connection. + // AI_API_KEY / AI_BASE_URL / AI_MODEL: read per request by @workflow-builder/ai-config. // 0 disables (dev default); the deploy compose sets both RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')), RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')), TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true', // Null = Turnstile verification disabled (local dev runs unprotected). TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null, - // AI adapt needs all three; any missing one disables the endpoint (returns 501). - // No built-in endpoint or model — nothing in the code points outside the network. - // The worker keeps its own copies. - AI_API_KEY: envOptional('AI_API_KEY'), - // Any OpenAI-compatible endpoint, including one inside your own network. - AI_BASE_URL: envOptional('AI_BASE_URL'), - AI_MODEL: envOptional('AI_MODEL'), }; diff --git a/apps/backend/src/routes/visualize.ts b/apps/backend/src/routes/visualize.ts index 2bf7ffe57..3ab610b98 100644 --- a/apps/backend/src/routes/visualize.ts +++ b/apps/backend/src/routes/visualize.ts @@ -3,8 +3,9 @@ import { generateText } from 'ai'; import { Hono } from 'hono'; import { z } from 'zod'; +import { aiConfig } from '@workflow-builder/ai-config'; + import type { AssertAuthorized, AuthVariables } from '../auth'; -import { env } from '../env'; import { logger as backendLogger } from '../logger'; import { guardExecution } from '../security/execution-guard'; import type { TenantVariables } from '../tenant'; @@ -50,10 +51,12 @@ export function createVisualizeRoutes( return blocked; } - const { AI_API_KEY: apiKey, AI_BASE_URL: baseURL, AI_MODEL: modelId } = env; - if (!apiKey || !baseURL || !modelId) { + // After authorization and the guard on purpose: an unconfigured server still gates the call. + const ai = aiConfig(); + if (!ai.available) { return c.json({ code: 'adapt_disabled', message: 'AI adapt is not configured on this server.' }, 501); } + const { apiKey, baseURL, modelId } = ai.config; const parsed = z.safeParse(adaptSchema, await c.req.json()); if (!parsed.success) { diff --git a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx index cbb43e5d9..a4dd4ffbe 100644 --- a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx +++ b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx @@ -154,7 +154,7 @@ Temporal ready [ai-studio] ➜ Local: http://127.0.0.1:4201/ ``` -Open [http://localhost:4201](http://localhost:4201). Pick the "Sales Inquiry" template, click Play. The Temporal UI at [http://localhost:8233](http://localhost:8233) shows the running execution. +Open [http://localhost:4201](http://localhost:4201). Every bundled template contains AI Agent nodes, so either connect an LLM first (next section) or expect the run to stop at its first AI Agent node with `ai_not_configured` while the Trigger, Decision and Visualize nodes before it run. Pick a template, click Play. The Temporal UI at [http://localhost:8233](http://localhost:8233) shows the running execution. To stop: `Ctrl+C`, then `pnpm infra:down`. diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index 0b5e1d73a..afb9418dc 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -23,6 +23,7 @@ TEMPORAL_TLS_KEY_PATH= # LLM for AI Agent nodes. Optional: leave empty and the worker still starts and # runs every other node type — AI Agent nodes then fail with `ai_not_configured`. +# The three AI_* variables are all-or-nothing (see packages/ai-config/README.md). # OpenRouter keys look like sk-or-v1-... AI_API_KEY= # Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index 04120ea7e..a6a70a712 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -38,10 +38,11 @@ See `.env.example`. Everything the bundled dev stack needs has a working default | `AI_MODEL` | Model id, as the endpoint spells it | — (AI Agent nodes fail) | | `TAVILY_API_KEY` | AI Agent's web-search tool (optional) | — (tool disabled) | -The three `AI_*` variables are optional by design: the worker boots without them and runs every -non-AI node, and an AI Agent node that is reached fails with the `ai_not_configured` code rather -than taking the whole worker down. `AI_API_KEY` was previously called `OPENROUTER_API_KEY`; the -old name is no longer read. +The three `AI_*` variables are optional by design, but all-or-nothing — the contract is described +once in [`@workflow-builder/ai-config`](../../packages/ai-config/README.md), which both apps read +through. The worker boots without them and runs every non-AI node, and an AI Agent node that is +reached fails with the `ai_not_configured` code rather than taking the whole worker down. +`AI_API_KEY` was previously called `OPENROUTER_API_KEY`; the old name is no longer read. Point `AI_BASE_URL` at any OpenAI-compatible server — a gateway, or a model hosted inside your own network — and no request leaves that network. There is no built-in endpoint or model: diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index 71022e906..d1c51274d 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -17,6 +17,7 @@ "@ai-sdk/openai-compatible": "catalog:", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", + "@workflow-builder/ai-config": "workspace:*", "@workflow-builder/execution-core": "workspace:*", "@workflow-builder/temporal-connection": "workspace:*", "@workflow-builder/types": "workspace:*", diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index bfa37b6de..569341e71 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -3,6 +3,7 @@ import { WorkflowBuilderPlugin } from '@workflowbuilder/temporal'; import 'dotenv/config'; import { fileURLToPath } from 'node:url'; +import { aiConfig } from '@workflow-builder/ai-config'; import { temporalConfig } from '@workflow-builder/temporal-connection'; import { database } from '../../database'; @@ -15,17 +16,15 @@ import { executeVisualize } from '../../executors/visualize'; import { logger } from '../../logger'; import { withPayloadSizeWarning } from '../../store-payload-warning'; -const missingAiConfig = (['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] as const).filter((name) => !env[name]); -if (missingAiConfig.length > 0) { +const ai = aiConfig(); +if (!ai.available) { logger.warn('AI not configured — AI Agent nodes will fail; every other node type runs as usual', { - missing: missingAiConfig, + missing: ai.missing, }); } const executeAIAgent = createAiAgentExecutor({ - apiKey: env.AI_API_KEY, - baseURL: env.AI_BASE_URL, - modelId: env.AI_MODEL, + ai, logger: logger.child({ component: 'ai-agent' }), tavilyApiKey: env.TAVILY_API_KEY, }); diff --git a/apps/execution-worker/src/env.test.ts b/apps/execution-worker/src/env.test.ts index 78005afd3..790976481 100644 --- a/apps/execution-worker/src/env.test.ts +++ b/apps/execution-worker/src/env.test.ts @@ -28,51 +28,25 @@ afterEach(() => { describe('loadEnv', () => { it('ignores variables inherited from the runner', async () => { - vi.stubEnv('AI_BASE_URL', 'http://ambient.example/v1'); + vi.stubEnv('TAVILY_API_KEY', 'ambient-key'); const env = await loadEnv({}); - expect(env.AI_BASE_URL).toBeNull(); + expect(env.TAVILY_API_KEY).toBeUndefined(); }); }); -describe('AI_API_KEY', () => { - // The worker used to throw at module load without a key. Booting keyless is the - // point: a deployment that runs no AI nodes should not need an LLM account. - it('is null when unset, rather than refusing to load', async () => { - const env = await loadEnv({}); - - expect(env.AI_API_KEY).toBeNull(); - }); - - it('reads AI_API_KEY', async () => { - const env = await loadEnv({ AI_API_KEY: 'key' }); - - expect(env.AI_API_KEY).toBe('key'); - }); - - // The alias was dropped rather than scoped: a provider-named key that silently - // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are - // no external deployments to keep working. Rename the variable in .env instead. - it('does not read the retired OPENROUTER_API_KEY name', async () => { - const env = await loadEnv({ OPENROUTER_API_KEY: 'old-key' }); - - expect(env.AI_API_KEY).toBeNull(); - }); -}); - -describe('AI_BASE_URL and AI_MODEL', () => { - // No built-in endpoint or model: the OpenRouter values live in .env.example only. - it('are null when unset', async () => { - const env = await loadEnv({}); +describe('TAVILY_API_KEY', () => { + // compose passes it through as `${TAVILY_API_KEY:-}`, so '' must disable the tool like unset does + it('reads an empty value as unset', async () => { + const env = await loadEnv({ TAVILY_API_KEY: '' }); - expect(env.AI_BASE_URL).toBeNull(); - expect(env.AI_MODEL).toBeNull(); + expect(env.TAVILY_API_KEY).toBeUndefined(); }); - it('points at any OpenAI-compatible endpoint', async () => { - const env = await loadEnv({ AI_BASE_URL: 'http://vllm.internal:8000/v1' }); + it('reads a key', async () => { + const env = await loadEnv({ TAVILY_API_KEY: 'tvly-key' }); - expect(env.AI_BASE_URL).toBe('http://vllm.internal:8000/v1'); + expect(env.TAVILY_API_KEY).toBe('tvly-key'); }); }); diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index 780782116..7e5d5b83a 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -2,24 +2,13 @@ function envOr(name: string, defaultValue: string): string { return process.env[name] ?? defaultValue; } -// Empty string counts as unset: compose passes absent optionals through as -// `${VAR:-}`, and a bare `?? null` would read '' as a configured value. -function envOptional(name: string): string | null { - return process.env[name] || null; -} - // Defaults use 127.0.0.1 (not `localhost`) to match the loopback-only docker // bindings; see apps/backend/src/env.ts for the full reason. export const env = { DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), - // TEMPORAL_*: read at startup by @workflow-builder/temporal-connection - // AI Agent nodes need all three. Any missing one: the worker still boots and runs - // every non-AI node; AI Agent nodes fail with `ai_not_configured` when reached. - // No built-in endpoint or model — nothing in the code points outside the network. - AI_API_KEY: envOptional('AI_API_KEY'), - // Any OpenAI-compatible endpoint, including one inside your own network. - AI_BASE_URL: envOptional('AI_BASE_URL'), - AI_MODEL: envOptional('AI_MODEL'), + // TEMPORAL_*: read at startup by @workflow-builder/temporal-connection. + // AI_API_KEY / AI_BASE_URL / AI_MODEL: read at startup by @workflow-builder/ai-config. // Optional. Enables the AI Agent's web-search tool; agents run without it when unset. - TAVILY_API_KEY: process.env['TAVILY_API_KEY'], + // Empty counts as unset: compose passes it through as `${TAVILY_API_KEY:-}`. + TAVILY_API_KEY: process.env['TAVILY_API_KEY'] || undefined, }; diff --git a/apps/execution-worker/src/executors/ai-agent.test.ts b/apps/execution-worker/src/executors/ai-agent.test.ts index 845528b54..087229b5c 100644 --- a/apps/execution-worker/src/executors/ai-agent.test.ts +++ b/apps/execution-worker/src/executors/ai-agent.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { aiConfig } from '@workflow-builder/ai-config'; import { type ExecutionContext, NodeExecutionError, @@ -27,10 +28,10 @@ const node: AiAgentNode = { config: { systemPrompt: 'Summarise the input.' }, }; -const baseOptions = { baseURL: 'https://openrouter.ai/api/v1', modelId: 'some/model' }; +const endpoint = { AI_BASE_URL: 'https://openrouter.ai/api/v1', AI_MODEL: 'some/model' }; describe('createAiAgentExecutor without a key', () => { - const executor = createAiAgentExecutor({ ...baseOptions, apiKey: null }); + const executor = createAiAgentExecutor({ ai: aiConfig(endpoint) }); it('fails the node instead of the worker boot', () => { // The factory itself must not throw — that is what lets the worker start and @@ -64,7 +65,7 @@ describe('createAiAgentExecutor with a key', () => { it('builds the executor without calling the endpoint', () => { // Construction is eager (the model is built once per worker), so it has to // stay free of network I/O — the endpoint may not even be reachable at boot. - const executor = createAiAgentExecutor({ ...baseOptions, apiKey: 'test-key' }); + const executor = createAiAgentExecutor({ ai: aiConfig({ ...endpoint, AI_API_KEY: 'test-key' }) }); expect(executor).toBeTypeOf('function'); }); @@ -73,7 +74,7 @@ describe('createAiAgentExecutor with a key', () => { describe('createAiAgentExecutor with a key but no endpoint or model', () => { // Neither has a built-in default, so they gate the node exactly like the key does. it('fails the node with the same code and names only the missing variables', () => { - const executor = createAiAgentExecutor({ apiKey: 'key', baseURL: null, modelId: null }); + const executor = createAiAgentExecutor({ ai: aiConfig({ AI_API_KEY: 'key' }) }); try { executor(node, context()); diff --git a/apps/execution-worker/src/executors/ai-agent.ts b/apps/execution-worker/src/executors/ai-agent.ts index 1f232c90d..3007807eb 100644 --- a/apps/execution-worker/src/executors/ai-agent.ts +++ b/apps/execution-worker/src/executors/ai-agent.ts @@ -1,30 +1,25 @@ // Builds the AI Agent executor, and decides what happens when the LLM is not configured. import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import type { AiConfigResult } from '@workflow-builder/ai-config'; import { type LoggerPort, type NodeExecutor, PermanentNodeExecutionError } from '@workflow-builder/execution-core'; import { executeAiAgent } from '../activities/ai-agent'; import type { AiAgentNode } from '../domain/ai-studio-nodes'; type AiAgentExecutorOptions = { - // Each null when not configured. The worker still boots; only this node type - // is unavailable, so a graph of Trigger/Decision/Visualize nodes runs fine. - apiKey: string | null; - baseURL: string | null; - modelId: string | null; + // Unavailable is allowed: the worker still boots; only this node type is + // unavailable, so a graph of Trigger/Decision/Visualize nodes runs fine. + ai: AiConfigResult; logger?: LoggerPort; tavilyApiKey?: string; }; export function createAiAgentExecutor(options: AiAgentExecutorOptions): NodeExecutor { - const { apiKey, baseURL, modelId, logger, tavilyApiKey } = options; + const { ai, logger, tavilyApiKey } = options; - if (!apiKey || !baseURL || !modelId) { - const missing = [ - ...(apiKey ? [] : ['AI_API_KEY']), - ...(baseURL ? [] : ['AI_BASE_URL']), - ...(modelId ? [] : ['AI_MODEL']), - ].join(', '); + if (!ai.available) { + const missing = ai.missing.join(', '); // Thrown when the node is reached rather than at boot, so missing config // costs one failed node instead of the whole worker. Permanent: a retry // cannot find configuration that is not there. @@ -36,6 +31,7 @@ export function createAiAgentExecutor(options: AiAgentExecutorOptions): NodeExec }; } + const { apiKey, baseURL, modelId } = ai.config; const provider = createOpenAICompatible({ name: 'ai', baseURL, apiKey }); const model = provider.chatModel(modelId); diff --git a/knip.config.js b/knip.config.js index f11bf3f2c..0737955c1 100644 --- a/knip.config.js +++ b/knip.config.js @@ -48,6 +48,9 @@ export default { 'packages/execution-core': { entry: ['src/index.ts'], }, + 'packages/ai-config': { + entry: ['src/index.ts'], + }, 'packages/temporal-connection': { // test/fixtures/tls-probe-workflow.ts is handed to Temporal's bundler by path, so nothing imports it entry: ['src/index.ts', 'test/fixtures/tls-probe-workflow.ts'], diff --git a/packages/ai-config/README.md b/packages/ai-config/README.md new file mode 100644 index 000000000..0467f7be2 --- /dev/null +++ b/packages/ai-config/README.md @@ -0,0 +1,36 @@ +# @workflow-builder/ai-config + +Private, source-only. The one place that says what "AI is configured" means for the reference backend and execution worker. + +## The contract + +Three variables, all or nothing: + +| Variable | Meaning | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `AI_API_KEY` | Key for the endpoint. OpenRouter keys look like `sk-or-v1-...` | +| `AI_BASE_URL` | Any OpenAI-compatible base URL (a hosted gateway or a model inside your own network), without a trailing `/chat/completions` | +| `AI_MODEL` | Model id as that endpoint spells it | + +- None has a built-in default — nothing in the code points outside your network. Both `.env.example` files pre-fill the OpenRouter values the stack used before the endpoint became configurable. +- An empty value counts as unset (compose passes absent optionals through as `${VAR:-}`). +- `OPENROUTER_API_KEY`, the old name of the key, is not read. + +```ts +import { aiConfig } from '@workflow-builder/ai-config'; + +const ai = aiConfig(); // reads process.env when called; never throws +// { available: true, config: { apiKey, baseURL, modelId } } +// { available: false, missing: ['AI_BASE_URL', 'AI_MODEL'] } +``` + +`TAVILY_API_KEY` is not part of this contract. It is a worker-only, independently optional key that enables the AI Agent's web-search tool on nodes that ask for it — see [`apps/execution-worker/README.md`](../../apps/execution-worker/README.md). + +## What happens when it is unavailable + +Deliberately not decided here. Each app reacts in its own way so that a missing model never blocks graphs without AI: + +- **Backend** — `POST /api/visualize/adapt` answers `501 adapt_disabled`, after authorization and the execution guard have run (`apps/backend/src/routes/visualize.ts`). +- **Worker** — boots, logs a warning naming the missing variables, and runs every node type. An AI Agent node that a run reaches fails with the permanent `ai_not_configured` code and the same names (`apps/execution-worker/src/executors/ai-agent.ts`). + +Logging, provider lifetime and retries also stay in the apps. Sharing the parser keeps the two readings of the rule identical; it cannot make two independently configured processes agree on the values — set the variables in both `.env` files. diff --git a/packages/ai-config/eslint.config.mjs b/packages/ai-config/eslint.config.mjs new file mode 100644 index 000000000..eee9610de --- /dev/null +++ b/packages/ai-config/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from '../../eslint.config.mjs'; diff --git a/packages/ai-config/lint-staged.config.mjs b/packages/ai-config/lint-staged.config.mjs new file mode 100644 index 000000000..63809e0a3 --- /dev/null +++ b/packages/ai-config/lint-staged.config.mjs @@ -0,0 +1 @@ +export { default } from '../../lint-staged.config.mjs'; diff --git a/packages/ai-config/package.json b/packages/ai-config/package.json new file mode 100644 index 000000000..305aedc96 --- /dev/null +++ b/packages/ai-config/package.json @@ -0,0 +1,20 @@ +{ + "name": "@workflow-builder/ai-config", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "vitest": "^3.0.4" + } +} diff --git a/packages/ai-config/src/index.test.ts b/packages/ai-config/src/index.test.ts new file mode 100644 index 000000000..8cd7ea649 --- /dev/null +++ b/packages/ai-config/src/index.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { aiConfig } from './index'; + +const complete = { + AI_API_KEY: 'sk-or-v1-key', + AI_BASE_URL: 'http://vllm.internal:8000/v1', + AI_MODEL: 'some/model', +}; + +describe('aiConfig', () => { + it('is available only when all three variables are set', () => { + expect(aiConfig(complete)).toEqual({ + available: true, + config: { apiKey: 'sk-or-v1-key', baseURL: 'http://vllm.internal:8000/v1', modelId: 'some/model' }, + }); + }); + + // Booting without an LLM is the point: a deployment that runs no AI nodes should + // not need an LLM account, so this never throws. + it('names every variable when nothing is set', () => { + expect(aiConfig({})).toEqual({ available: false, missing: ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] }); + }); + + it.each([ + ['AI_API_KEY', ['AI_API_KEY']], + ['AI_BASE_URL', ['AI_BASE_URL']], + ['AI_MODEL', ['AI_MODEL']], + ] as const)('names only the missing variable when %s is absent', (absent, missing) => { + const env: NodeJS.ProcessEnv = { ...complete }; + delete env[absent]; + + expect(aiConfig(env)).toEqual({ available: false, missing }); + }); + + it('names two missing variables in declaration order', () => { + expect(aiConfig({ AI_API_KEY: 'key' })).toEqual({ available: false, missing: ['AI_BASE_URL', 'AI_MODEL'] }); + }); + + // compose passes absent optionals through as `${VAR:-}`, so '' must not count as configured + it('treats an empty string like an unset variable', () => { + expect(aiConfig({ ...complete, AI_MODEL: '' })).toEqual({ available: false, missing: ['AI_MODEL'] }); + }); + + // The alias was dropped rather than scoped: a provider-named key that silently + // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are + // no external deployments to keep working. Rename the variable in .env instead. + it('does not read the retired OPENROUTER_API_KEY name', () => { + expect(aiConfig({ ...complete, AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' })).toEqual({ + available: false, + missing: ['AI_API_KEY'], + }); + }); + + it('reads process.env when no environment is given', () => { + vi.stubEnv('AI_API_KEY', 'from-process-env'); + vi.stubEnv('AI_BASE_URL', complete.AI_BASE_URL); + vi.stubEnv('AI_MODEL', complete.AI_MODEL); + try { + expect(aiConfig()).toMatchObject({ available: true, config: { apiKey: 'from-process-env' } }); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/ai-config/src/index.ts b/packages/ai-config/src/index.ts new file mode 100644 index 000000000..ba8300fdf --- /dev/null +++ b/packages/ai-config/src/index.ts @@ -0,0 +1,24 @@ +const AI_VARIABLES = ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] as const; + +export type AiVariable = (typeof AI_VARIABLES)[number]; + +export type AiConfig = { apiKey: string; baseURL: string; modelId: string }; + +// Either everything an OpenAI-compatible client needs, or which variables are missing. +// What to do about `available: false` is each app's call: the backend answers 501, the +// worker boots and fails an AI Agent node only when a run reaches one. +export type AiConfigResult = { available: true; config: AiConfig } | { available: false; missing: AiVariable[] }; + +export function aiConfig(env: NodeJS.ProcessEnv = process.env): AiConfigResult { + // Empty string counts as unset: compose passes absent optionals through as + // `${VAR:-}`, and a bare `?? null` would read '' as a configured value. + const value = (name: AiVariable) => env[name] || null; + const apiKey = value('AI_API_KEY'); + const baseURL = value('AI_BASE_URL'); + const modelId = value('AI_MODEL'); + + // No built-in endpoint or model: nothing in the code points outside the network. + return apiKey && baseURL && modelId + ? { available: true, config: { apiKey, baseURL, modelId } } + : { available: false, missing: AI_VARIABLES.filter((name) => !value(name)) }; +} diff --git a/packages/ai-config/tsconfig.json b/packages/ai-config/tsconfig.json new file mode 100644 index 000000000..08eedd0d1 --- /dev/null +++ b/packages/ai-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022"], + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944b715d7..61ab82a5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: '@temporalio/client': specifier: 'catalog:' version: 1.23.0 + '@workflow-builder/ai-config': + specifier: workspace:* + version: link:../../packages/ai-config '@workflow-builder/execution-core': specifier: workspace:* version: link:../../packages/execution-core @@ -456,6 +459,9 @@ importers: '@temporalio/workflow': specifier: 'catalog:' version: 1.23.0 + '@workflow-builder/ai-config': + specifier: workspace:* + version: link:../../packages/ai-config '@workflow-builder/execution-core': specifier: workspace:* version: link:../../packages/execution-core @@ -522,6 +528,15 @@ importers: specifier: ^2.19.2 version: 2.20.0 + packages/ai-config: + devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.12.0 + vitest: + specifier: ^3.0.4 + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + packages/execution-core: dependencies: '@workflow-builder/types': From e9ccc5bcccd65fb12dca5efd3d354f73a4f145ad Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 16:18:17 +0200 Subject: [PATCH 24/27] docs: limit the "no external traffic" promise to model requests Several docs said an internal AI_BASE_URL keeps all traffic in your network, or that nothing in the code points outside it. The optional web-search tool calls Tavily's API whenever TAVILY_API_KEY is set, a node enables search and the model invokes the tool, regardless of where the model runs. Say "model requests stay inside it" instead, note that the Tavily key must stay unset if nothing may call out, and remind readers that Temporal and the database go wherever their addresses point. Wording only; no behaviour changed. --- README.md | 2 +- .../content/docs/get-started/quick-start/standalone-app.mdx | 2 +- apps/execution-worker/README.md | 5 ++++- deploy/ai-studio/.env.example | 5 +++-- packages/ai-config/README.md | 4 ++-- packages/ai-config/src/index.ts | 2 +- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 991e1f298..2922fbf08 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ AI_BASE_URL=https://openrouter.ai/api/v1 AI_MODEL=mistralai/mistral-small-3.2-24b-instruct ``` -None of the three has a built-in default — nothing in the code points outside your network. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and no LLM traffic leaves it. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. +None of the three has a built-in default. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and model requests stay inside it. That covers the model only: the optional web-search tool calls Tavily's API when `TAVILY_API_KEY` is set, so leave it unset if nothing may call out. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. ### Troubleshooting diff --git a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx index a4dd4ffbe..7defeb33c 100644 --- a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx +++ b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx @@ -168,7 +168,7 @@ AI_BASE_URL=https://openrouter.ai/api/v1 AI_MODEL=mistralai/mistral-small-3.2-24b-instruct ``` -None of the three has a built-in default — nothing in the code points outside your network. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and no LLM traffic leaves it. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. +None of the three has a built-in default. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and model requests stay inside it. That covers the model only: the optional web-search tool calls Tavily's API when `TAVILY_API_KEY` is set, so leave it unset if nothing may call out. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. ### Connect a secured or external Temporal (optional) diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index a6a70a712..b2140ba7e 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -45,7 +45,10 @@ reached fails with the `ai_not_configured` code rather than taking the whole wor `AI_API_KEY` was previously called `OPENROUTER_API_KEY`; the old name is no longer read. Point `AI_BASE_URL` at any OpenAI-compatible server — a gateway, or a model hosted inside your -own network — and no request leaves that network. There is no built-in endpoint or model: +own network — and model requests stay inside it. That covers the model only: the optional +web-search tool calls Tavily's API whenever `TAVILY_API_KEY` is set, a node enables web search and +the model invokes the tool, so leave the key unset if nothing may call out; Temporal and the +database go wherever `TEMPORAL_ADDRESS` and `DATABASE_URL` point. There is no built-in endpoint or model: `.env.example` pre-fills the OpenRouter values the worker used before they became configurable. The connection to Temporal is env-driven too: `TEMPORAL_TLS`, `TEMPORAL_API_KEY` and the diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index c1602f9ab..c70d6e44f 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -14,8 +14,9 @@ AI_API_KEY= # Any OpenAI-compatible endpoint. There is no built-in default: the value below # is the OpenRouter setup the stack used before the endpoint became configurable. -# Point it at a gateway or a model inside your own network and no LLM traffic -# leaves it. Pre-filled with OpenRouter's URL; there is no built-in default. +# Point it at a gateway or a model inside your own network and model requests +# stay inside it (the web search below is separate: leave TAVILY_API_KEY empty +# if nothing may call out). AI_BASE_URL=https://openrouter.ai/api/v1 # Model id as the endpoint above understands it. This one diff --git a/packages/ai-config/README.md b/packages/ai-config/README.md index 0467f7be2..58b8e929c 100644 --- a/packages/ai-config/README.md +++ b/packages/ai-config/README.md @@ -12,7 +12,7 @@ Three variables, all or nothing: | `AI_BASE_URL` | Any OpenAI-compatible base URL (a hosted gateway or a model inside your own network), without a trailing `/chat/completions` | | `AI_MODEL` | Model id as that endpoint spells it | -- None has a built-in default — nothing in the code points outside your network. Both `.env.example` files pre-fill the OpenRouter values the stack used before the endpoint became configurable. +- None has a built-in default: with the three unset there is no model endpoint to call. Both `.env.example` files pre-fill the OpenRouter values the stack used before the endpoint became configurable. - An empty value counts as unset (compose passes absent optionals through as `${VAR:-}`). - `OPENROUTER_API_KEY`, the old name of the key, is not read. @@ -24,7 +24,7 @@ const ai = aiConfig(); // reads process.env when called; never throws // { available: false, missing: ['AI_BASE_URL', 'AI_MODEL'] } ``` -`TAVILY_API_KEY` is not part of this contract. It is a worker-only, independently optional key that enables the AI Agent's web-search tool on nodes that ask for it — see [`apps/execution-worker/README.md`](../../apps/execution-worker/README.md). +`TAVILY_API_KEY` is not part of this contract. It is a worker-only, independently optional key that enables the AI Agent's web-search tool on nodes that ask for it, and the one other outbound call an AI Agent node can make — an internal `AI_BASE_URL` keeps model requests in your network, but only an unset Tavily key keeps the search from calling out — see [`apps/execution-worker/README.md`](../../apps/execution-worker/README.md). ## What happens when it is unavailable diff --git a/packages/ai-config/src/index.ts b/packages/ai-config/src/index.ts index ba8300fdf..2f4b68697 100644 --- a/packages/ai-config/src/index.ts +++ b/packages/ai-config/src/index.ts @@ -17,7 +17,7 @@ export function aiConfig(env: NodeJS.ProcessEnv = process.env): AiConfigResult { const baseURL = value('AI_BASE_URL'); const modelId = value('AI_MODEL'); - // No built-in endpoint or model: nothing in the code points outside the network. + // No built-in endpoint or model: unset means there is no model endpoint to call. return apiKey && baseURL && modelId ? { available: true, config: { apiKey, baseURL, modelId } } : { available: false, missing: AI_VARIABLES.filter((name) => !value(name)) }; From d6bc0321e58cad8eca9cc9a576212bc6cae085ac Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 16:27:27 +0200 Subject: [PATCH 25/27] refactor: drop a header that restated the function, name the PEM reader Remove the file header on the AI agent executor factory that repeated what its name and body already say. The comments explaining the real contracts stay: TEMPORAL_TLS is tri-state on purpose, and the AI configuration error is deferred to node execution so a keyless worker still boots. Rename the certificate reader readPemFile so call sites say what kind of file they read. --- apps/execution-worker/src/executors/ai-agent.ts | 1 - packages/temporal-connection/src/index.ts | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/execution-worker/src/executors/ai-agent.ts b/apps/execution-worker/src/executors/ai-agent.ts index 3007807eb..25c801a93 100644 --- a/apps/execution-worker/src/executors/ai-agent.ts +++ b/apps/execution-worker/src/executors/ai-agent.ts @@ -1,4 +1,3 @@ -// Builds the AI Agent executor, and decides what happens when the LLM is not configured. import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import type { AiConfigResult } from '@workflow-builder/ai-config'; diff --git a/packages/temporal-connection/src/index.ts b/packages/temporal-connection/src/index.ts index 25781a33d..100f4928e 100644 --- a/packages/temporal-connection/src/index.ts +++ b/packages/temporal-connection/src/index.ts @@ -79,12 +79,12 @@ function connectionOptions( } const certificates: TemporalTlsOptions = { - ...(caPath ? { serverRootCACertificate: readPem(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), + ...(caPath ? { serverRootCACertificate: readPemFile(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), ...(certPath && keyPath ? { clientCertPair: { - crt: readPem(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), - key: readPem(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), + crt: readPemFile(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), + key: readPemFile(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), }, } : {}), @@ -110,7 +110,7 @@ function read(env: NodeJS.ProcessEnv): Config { }; } -function readPem(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { +function readPemFile(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { try { return readFile(path); } catch (error) { From 1dbca0581325cccdc4e7cff0e47536dfa45c0fdf Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 17:20:27 +0200 Subject: [PATCH 26/27] test(temporal-connection): remove the TLS test's temp PKI directories --- packages/temporal-connection/test/harness/certificates.ts | 5 +++-- packages/temporal-connection/test/tls.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/temporal-connection/test/harness/certificates.ts b/packages/temporal-connection/test/harness/certificates.ts index 886354b6b..5206774ba 100644 --- a/packages/temporal-connection/test/harness/certificates.ts +++ b/packages/temporal-connection/test/harness/certificates.ts @@ -8,8 +8,8 @@ export type PemPair = { cert: string; key: string }; /** A throwaway CA with one server leaf (SAN localhost / 127.0.0.1 / ::1) and one client leaf. */ export type TestPki = { ca: PemPair; server: PemPair; client: PemPair }; -/** The PEM files a TEMPORAL_TLS_*_PATH-style config can point at. */ -export type TestPkiFiles = { ca: string; clientCert: string; clientKey: string }; +/** The PEM files a TEMPORAL_TLS_*_PATH-style config can point at; `directory` holds them all, for cleanup. */ +export type TestPkiFiles = { directory: string; ca: string; clientCert: string; clientKey: string }; type Issued = { cert: forge.pki.Certificate; key: forge.pki.rsa.PrivateKey; pem: PemPair }; @@ -66,6 +66,7 @@ export function createTestPki(name: string): TestPki { export function writeTestPki(pki: TestPki, name: string): TestPkiFiles { const directory = mkdtempSync(path.join(tmpdir(), `wb-tls-${name}-`)); const files = { + directory, ca: path.join(directory, 'ca.pem'), clientCert: path.join(directory, 'client.pem'), clientKey: path.join(directory, 'client-key.pem'), diff --git a/packages/temporal-connection/test/tls.test.ts b/packages/temporal-connection/test/tls.test.ts index f4e2b1530..82e7a3644 100644 --- a/packages/temporal-connection/test/tls.test.ts +++ b/packages/temporal-connection/test/tls.test.ts @@ -5,6 +5,7 @@ import { Client, Connection } from '@temporalio/client'; import { TestWorkflowEnvironment } from '@temporalio/testing'; import { NativeConnection, Worker } from '@temporalio/worker'; +import { rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -64,6 +65,9 @@ beforeAll(async () => { afterAll(async () => { await env?.teardown(); + for (const minted of [trusted, stranger]) { + if (minted) rmSync(minted.files.directory, { recursive: true, force: true }); + } }); describe.each(transports)('$name over TLS', ({ connect }) => { From 108e9985c85c83199ef4699c51e392b69b68f6fe Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Tue, 8 Sep 2026 17:20:46 +0200 Subject: [PATCH 27/27] docs(deploy): the backend calls the LLM too, for the visualize route --- deploy/ai-studio/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index d2590315d..e361053b4 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -5,15 +5,15 @@ any Docker host — an Azure VM, AWS, on-prem — with no cloud-specific glue. ## What runs -| Service | Image | Role | Exposed | -| ------------- | ------------------------------ | --------------------------------------------- | ------------------------ | -| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | -| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal | -| `worker` | `ai-studio-runtime` | Temporal worker, makes the LLM calls | internal | -| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | -| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | -| `temporal-db` | `postgres:16` | Temporal's own state store | internal | -| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | +| Service | Image | Role | Exposed | +| ------------- | ------------------------------ | ---------------------------------------------------------------------- | ------------------------ | +| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | +| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream; calls the LLM for `/api/visualize/adapt` | internal | +| `worker` | `ai-studio-runtime` | Temporal worker, runs the nodes; AI Agent nodes call the LLM | internal | +| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | +| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | +| `temporal-db` | `postgres:16` | Temporal's own state store | internal | +| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | The three Temporal rows come from [`docker-compose.override.yml`](docker-compose.override.yml), which compose