From c4125fee6c55200e8ab3f5b340092baff0cecc44 Mon Sep 17 00:00:00 2001 From: Jeongwan Jeon Date: Sat, 22 Aug 2026 04:36:32 +0900 Subject: [PATCH] Refuse to start the built-in Bot without a model key, so the healthcheck stops passing a Bot that cannot answer --- .github/workflows/ci.yml | 4 +++ CHANGELOG.md | 4 +++ agent-bot/src/index.ts | 19 +++++++++++++- tests/agent-bot.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/agent-bot.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a14feea..14225907 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,10 @@ jobs: with: bun-version: 1.3.14 - run: bun install --frozen-lockfile + # The Bots are not root workspaces and each keeps its own lockfile. The startup tests spawn + # the real entrypoint, so its dependencies have to be installed as well. + - run: bun install --frozen-lockfile + working-directory: agent-bot # Not the db:migrate script: that one loads ../.env, which does not exist in CI. DATABASE_URL # comes from the job env instead, which drizzle.config.ts already reads. - run: bunx drizzle-kit migrate --config=drizzle.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b92b13..01a6c0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ digit. The same rule container and volume names have always followed. A deployme `AUDIT_RETENTION_DAYS` is new and unset, which keeps the audit trail forever, as before. Set it to a whole number of days to have old rows removed. +The built-in Bot refuses to start without `OPENAI_API_KEY`. It used to start, report healthy, and +then fail every conversation, so a missing key looked like a working deployment. The LangGraph Bot +already refused the same way. + Sessions survive and nobody signs in again. ### Added diff --git a/agent-bot/src/index.ts b/agent-bot/src/index.ts index 3fecd861..e48d318d 100644 --- a/agent-bot/src/index.ts +++ b/agent-bot/src/index.ts @@ -45,8 +45,25 @@ const MODEL = process.env.BOT_MODEL ?? "gpt-5.5"; */ const BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined; +/** + * The key that model is answered with, checked at startup rather than on the first conversation. + * + * Without the check the Bot starts, answers the healthcheck, and then fails every run, so the + * compose healthcheck reports a Bot that cannot answer as healthy. The LangGraph Bot already + * refuses to start without its provider's key, and this file already refuses without its token + * above; the model key was the one configuration that escaped the same posture. A missing key + * should fail in front of whoever is deploying, not in front of whoever is asking. + */ +const API_KEY = process.env.OPENAI_API_KEY?.trim(); +if (!API_KEY) { + console.error( + "OPENAI_API_KEY is not set. This Bot cannot answer without a model.", + ); + process.exit(1); +} + const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + apiKey: API_KEY, baseURL: BASE_URL, }); diff --git a/tests/agent-bot.test.ts b/tests/agent-bot.test.ts new file mode 100644 index 00000000..201c72a1 --- /dev/null +++ b/tests/agent-bot.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; + +/** + * The compose healthcheck asks `/health`, and `/health` answers without consulting the model key. + * The refusal to run without one therefore has to happen before the server listens: a Bot that + * cannot answer should not be running, let alone reporting healthy. These spawn the real + * entrypoint with the environment compose would hand it. + */ + +async function startBot(environment: Record) { + const proc = Bun.spawn( + ["bun", join(import.meta.dir, "..", "agent-bot", "src", "index.ts")], + { + // Every variable the repository's `.env` could inject is named explicitly: bun loads that + // file into the child, and a leaked token or key would let a configuration under test pass + // a check it is supposed to fail. + env: { + PATH: process.env.PATH ?? "", + MANAGED_AGENT_TOKEN: "", + OPENAI_API_KEY: "", + ...environment, + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + + // Both configurations under test exit before the server listens. If one reaches `serve` anyway, + // kill it so the test fails on the missing refusal rather than on bun's test timeout. + const killer = setTimeout(() => proc.kill(), 5_000); + const exitCode = await proc.exited; + clearTimeout(killer); + const stderr = await new Response(proc.stderr).text(); + return { exitCode, stderr }; +} + +test("agent-bot refuses to start when the model key is empty", async () => { + // Compose passes `${OPENAI_API_KEY:-}`, so an unset key arrives as an empty + // string rather than missing altogether. + const { exitCode, stderr } = await startBot({ + MANAGED_AGENT_TOKEN: "test-token", + OPENAI_API_KEY: "", + }); + + expect(exitCode).toBe(1); + expect(stderr).toContain("OPENAI_API_KEY is not set"); +}); + +test("agent-bot still refuses to start without its server token", async () => { + const { exitCode, stderr } = await startBot({ + OPENAI_API_KEY: "sk-test", + }); + + expect(exitCode).toBe(1); + expect(stderr).toContain("MANAGED_AGENT_TOKEN is not set"); +});