Skip to content

feat: connection config — OpenAI-compatible LLM endpoint and Temporal namespace/TLS/API-key auth - #113

Open
dawidaksamski wants to merge 18 commits into
mainfrom
feat/WB-596-connection-config
Open

feat: connection config — OpenAI-compatible LLM endpoint and Temporal namespace/TLS/API-key auth#113
dawidaksamski wants to merge 18 commits into
mainfrom
feat/WB-596-connection-config

Conversation

@dawidaksamski

@dawidaksamski dawidaksamski commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Makes the reference stack's outbound connections configurable without code changes, and makes the code itself point nowhere by default.

The backend and worker build their model through the generic @ai-sdk/openai-compatible provider driven by AI_API_KEY, AI_BASE_URL and AI_MODEL. None of the three has a built-in value: the OpenRouter endpoint and model live in .env.example only, so any OpenAI-compatible endpoint works, including one inside the customer's network, and nothing in the code names an external host. AI is configured only when all three are set; otherwise the stack fails closed. The worker boots keyless and runs every other node type, an AI Agent node reached without configuration fails with ai_not_configured (a PermanentNodeExecutionError, so it is not retried and its code survives the Temporal activity boundary), and the backend's adapt route returns 501. Both processes accept TEMPORAL_NAMESPACE plus optional TLS, mTLS and API-key configuration, validated fail-loud through a unit-tested option builder. Defaults preserve today's plaintext local-dev Temporal behaviour exactly.

Closes the first subtask of WB-596 (WB-600).

What changed

Area Change
LLM provider @openrouter/ai-sdk-provider@ai-sdk/openai-compatible, driven by AI_BASE_URL; no built-in endpoint or model
LLM key AI_API_KEY replaces OPENROUTER_API_KEY, which is no longer read anywhere (see "Upgrading")
Fail-closed Worker boots keyless and warns which of the three variables are missing; AI Agent nodes fail with ai_not_configured (permanent, one attempt); adapt route returns 501
Temporal TEMPORAL_NAMESPACE plus optional TEMPORAL_TLS (tri-state), TEMPORAL_API_KEY, TEMPORAL_TLS_CA_PATH / _CERT_PATH / _KEY_PATH, in backend and worker; contradictory combinations rejected when the connection opens
Deploy: parity One x-temporal-env anchor feeds every TEMPORAL_* variable to backend and worker, so the two cannot drift
Deploy: mTLS ./tls (git-ignored, excluded from the image build context, TEMPORAL_TLS_DIR to override) mounted read-only into both containers at /etc/workflowbuilder/tls
Deploy: external cluster The bundled Temporal cluster, its debug UI and the start-order edges moved to docker-compose.override.yml (applied by default); COMPOSE_FILE=docker-compose.yml in .env leaves it out
Deploy: workflow Runs compose from the project directory (no -f, so the override and COMPOSE_FILE apply), ships both compose files to the VM on every deploy, passes pushed tags as RUNTIME_IMAGE / WEB_IMAGE
Deploy: guard Compose refuses to start while OPENROUTER_API_KEY is still set, naming the rename and the two new variables
Deps ai and @ai-sdk/openai-compatible moved into the pnpm catalog; their majors are coupled (v2ai v6, v3ai v7)

Documentation covers all surfaces: both app READMEs, the deploy README, the docs-site quick start (new "secured or external Temporal" section with Temporal Cloud and mTLS examples) and the root README. The earlier claim that "AI Studio works with stub responses out of the box" was never true and is gone; the stack now genuinely runs without an LLM and the docs describe what happens.

Upgrading an existing deployment

In .env, rename OPENROUTER_API_KEY to AI_API_KEY and add AI_BASE_URL and AI_MODEL (values in .env.example). Compose fails fast with a message while the old name is present, so a stale .env cannot come up with AI silently off. The demo VM's .env needs this before the next deploy, and the deploy workflow now overwrites the VM's compose files with the repo's, so any VM-local customisation must be ported to the repo first.

Verification

Automated: backend 117, worker 32, execution-core 168, @workflowbuilder/temporal 83 tests pass; lint, typecheck and prettier are clean for every touched workspace; the docs site builds. The env tests unset every variable the module reads before each import, so they are independent of the runner's environment.

Through a real boundary: packages/temporal/test/error-boundary.test.ts runs graphs against a real Temporal dev server and asserts that a permanent throw stops after one attempt with its code in node_failed and in the workflow's failure type, while an unclassified throw retries per the profile and is reported exactly as before.

Against Docker:

  • Both compose modes render as intended (docker compose config): default includes the bundled cluster and edges, external mode has no temporal* services and the apps depend only on app-db; TEMPORAL_* is identical between backend and worker in both.
  • Live smoke tests of both modes: default with the bundled cluster, and external against a Temporal outside the project (host.docker.internal), worker RUNNING with zero restarts in each.
  • The tls/ mount is visible and write-rejected inside a running container; a PEM dropped there is git-ignored and absent from the image build context (probe build fails to COPY it).
  • The stale-key guard: a verbatim .env.example and an empty environment start; a .env with only OPENROUTER_API_KEY is refused in both modes; a correctly renamed one starts.

Not verified: a real TLS/mTLS handshake against a secured cluster or Temporal Cloud. Neither CI nor a dev machine has one; the option shapes are pinned by tests and checked against both SDKs' types.

Known, deferred

Found by an internal multi-perspective review and left out deliberately; none blocks the change.

  • packages/temporal client factory caches a rejected connection promise until restart (pre-existing; this PR moves config validation into that factory). Fix: reset the promise on rejection.
  • Backend onError logs error.message only, dropping the cause the builder attaches (ENOENT vs EACCES on a PEM path).
  • Switching to an external cluster leaves the ai-studio_temporal-db-data volume behind; docs should add docker volume rm.
  • API keys travel as container environment (visible via docker inspect); file-based *_API_KEY_PATH variants are a follow-up.
  • Nits: plaintext to a non-loopback Temporal address is silent; the "OS trust store" wording is accurate for the worker's Rust core, not Node's client; the env-test harness relies on env keys equalling variable names; a drift test between the two temporal-connection.ts copies; a few comments to trim; the worker README's structure tree omits the new files; the pre-existing WB-229 id in the compose header.

To consider: the duplicated connection builder

buildTemporalConnectionOptions exists twice, verbatim apart from one type import (apps/backend/src/engine/temporal-connection.ts, apps/execution-worker/src/engines/temporal/temporal-connection.ts), with mirrored tests. Deliberate: the two SDKs type apiKey differently, there is no shared config package, and a new workspace costs tsconfig, knip and lockfile wiring. Against it: this is now the largest duplicated block in the repo and it is security-relevant; if the copies drift, backend and worker could disagree about whether TLS is on, and the mirrored tests are the only thing catching that. Recommendation: leave it for this PR and revisit if a third consumer appears, or add the drift test listed above as a cheap middle ground.

@dawidaksamski dawidaksamski self-assigned this Sep 1, 2026
@dawidaksamski
dawidaksamski marked this pull request as ready for review September 2, 2026 07:23
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-connection-config branch from d348521 to cf4f5de Compare September 3, 2026 11:01
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.
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.
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.
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.
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.
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.
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-connection-config branch from cf4f5de to 168c5d9 Compare September 4, 2026 11:50
…the VM

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.
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.
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants