diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c658b496..abb7b3935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### An empty Bot `PORT` is unset, so NaN never reaches Bun.serve + +`PORT=` on `agent-bot` and `agent-langgraph` used to parse as `NaN` (`??` does not treat empty as absent) and `Bun.serve` bound an ephemeral port while compose still published 4200/4201. A prefix typo (`42o0`) started on 42. Empty now means the shipped default; anything that is not a whole port number refuses to start. + ### The server connects to Postgres on Windows, and `localhost` is no longer a coin toss Two separate faults, both of which stop a deployment reaching its own database and neither of which diff --git a/agent-bot/src/index.ts b/agent-bot/src/index.ts index b05cf02d4..3824c5d9a 100644 --- a/agent-bot/src/index.ts +++ b/agent-bot/src/index.ts @@ -3,6 +3,7 @@ import { EventEncoder } from "@ag-ui/encoder"; import { serve } from "bun"; import OpenAI from "openai"; import { hasManagedAgentToken } from "../../shared/agent-authorisation"; +import { listenPort } from "../../shared/listen-port"; import { toProviderMessages } from "./history"; /** @@ -16,7 +17,12 @@ import { toProviderMessages } from "./history"; * running where its effects are visible to the person watching. */ -const PORT = Number.parseInt(process.env.PORT ?? "4200", 10); +const resolvedPort = listenPort(process.env.PORT, 4200); +if (!resolvedPort.ok) { + console.error(resolvedPort.reason); + process.exit(1); +} +const PORT = resolvedPort.port; const MANAGED_AGENT_TOKEN = process.env.MANAGED_AGENT_TOKEN?.trim(); if (!MANAGED_AGENT_TOKEN) { console.error( diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index fc7ed28f2..5159faa11 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -12,6 +12,7 @@ import { import { ChatOpenAI } from "@langchain/openai"; import { serve } from "bun"; import { hasManagedAgentToken } from "../../shared/agent-authorisation"; +import { listenPort } from "../../shared/listen-port"; import { toLangChainMessages } from "./history"; import { readReasoningEffort } from "./model-options"; import { streamRun } from "./stream"; @@ -36,7 +37,12 @@ import { streamRun } from "./stream"; * The graph provides model orchestration without changing that contract. */ -const PORT = Number.parseInt(process.env.PORT ?? "4201", 10); +const resolvedPort = listenPort(process.env.PORT, 4201); +if (!resolvedPort.ok) { + console.error(resolvedPort.reason); + process.exit(1); +} +const PORT = resolvedPort.port; const MANAGED_AGENT_TOKEN = process.env.MANAGED_AGENT_TOKEN?.trim(); if (!MANAGED_AGENT_TOKEN) { console.error( diff --git a/shared/listen-port.test.ts b/shared/listen-port.test.ts new file mode 100644 index 000000000..ce50da317 --- /dev/null +++ b/shared/listen-port.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { listenPort } from "./listen-port"; + +/** + * Empty PORT must not become NaN / an ephemeral bind. Same empty-string trap as the supervisor. + */ +describe("Bot listen port", () => { + test("unset and empty string fall back", () => { + expect(listenPort(undefined, 4200)).toEqual({ ok: true, port: 4200 }); + expect(listenPort("", 4201)).toEqual({ ok: true, port: 4201 }); + expect(listenPort(" ", 4200)).toEqual({ ok: true, port: 4200 }); + }); + + test("a whole number in range is accepted", () => { + expect(listenPort("4200", 4200)).toEqual({ ok: true, port: 4200 }); + expect(listenPort("4500", 4200)).toEqual({ ok: true, port: 4500 }); + expect(listenPort("1", 4200)).toEqual({ ok: true, port: 1 }); + expect(listenPort("65535", 4200)).toEqual({ ok: true, port: 65535 }); + }); + + test("prefix typos and out-of-range values are refused", () => { + expect(listenPort("42o0", 4200).ok).toBe(false); + expect(listenPort("0", 4200).ok).toBe(false); + expect(listenPort("65536", 4200).ok).toBe(false); + expect(listenPort("-1", 4200).ok).toBe(false); + expect(listenPort("1.5", 4200).ok).toBe(false); + }); +}); diff --git a/shared/listen-port.ts b/shared/listen-port.ts new file mode 100644 index 000000000..f195fd11b --- /dev/null +++ b/shared/listen-port.ts @@ -0,0 +1,30 @@ +/** + * Listen port for a Bot process. + * + * An empty `PORT=` (compose blank, leftover `.env` line) is unset, not zero — the same empty-string + * trap #96/#114/#312/#343 found for the server, computer, and supervisor. `??` only fires on + * undefined, so `Number.parseInt("", 10)` used to be `NaN` and `Bun.serve({ port: NaN })` bound an + * ephemeral port while compose still published 4200/4201. Prefix typos (`42o0`) also used to start + * on 42 via parseInt. + */ +export function listenPort( + raw: string | undefined, + fallback: number, +): { ok: true; port: number } | { ok: false; reason: string } { + const trimmed = raw?.trim(); + if (!trimmed) return { ok: true, port: fallback }; + if (!/^\d+$/.test(trimmed)) { + return { + ok: false, + reason: `PORT must be a whole number from 1 to 65535 (got ${JSON.stringify(raw)}).`, + }; + } + const value = Number.parseInt(trimmed, 10); + if (value < 1 || value > 65535) { + return { + ok: false, + reason: `PORT must be a whole number from 1 to 65535 (got ${JSON.stringify(raw)}).`, + }; + } + return { ok: true, port: value }; +}