diff --git a/agent-computer/src/env.ts b/agent-computer/src/env.ts new file mode 100644 index 00000000..2cd93262 --- /dev/null +++ b/agent-computer/src/env.ts @@ -0,0 +1,16 @@ +/** + * A positive number from the environment, or the fallback. + * + * `Number.parseInt(process.env.X ?? "default")` is not enough: an unset variable declared in a + * compose file arrives as an empty string rather than as absent, so `??` never fires and the parse + * yields `NaN`. Empty, absent, non-numeric and non-positive all mean "not set" and take the fallback. + * + * Its own module, free of the `playwright` import `profiles.ts` carries, so a test can reach it + * without loading a browser driver that is not installed where the tests run. + */ +export function numberFromEnv(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? value : fallback; +} diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 98a6c5f7..9441f7e0 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -12,7 +12,7 @@ import { TAKE_CONTROL_FIRST, } from "./control"; import { identity } from "./identity"; -import { createProfiles, VIEWPORT } from "./profiles"; +import { createProfiles, numberFromEnv, VIEWPORT } from "./profiles"; import { type InputMessage, type Screencast, @@ -70,11 +70,8 @@ if (!COMPUTER_TOKEN) { process.exit(1); } -const PORT = Number.parseInt(process.env.PORT ?? "4100", 10); -const NAVIGATION_TIMEOUT_MS = Number.parseInt( - process.env.NAVIGATION_TIMEOUT_MS ?? "30000", - 10, -); +const PORT = numberFromEnv("PORT", 4100); +const NAVIGATION_TIMEOUT_MS = numberFromEnv("NAVIGATION_TIMEOUT_MS", 30000); /** * How long one action waits for its element. @@ -83,10 +80,7 @@ const NAVIGATION_TIMEOUT_MS = Number.parseInt( * behaviour we want, but a ref that no longer resolves would otherwise hang for the full navigation * timeout before saying so, and the person is sitting watching a screen that is not changing. */ -const ACTION_TIMEOUT_MS = Number.parseInt( - process.env.ACTION_TIMEOUT_MS ?? "10000", - 10, -); +const ACTION_TIMEOUT_MS = numberFromEnv("ACTION_TIMEOUT_MS", 10000); /** * How much page text a navigation hands back. diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts index b0b2e714..420931ee 100644 --- a/agent-computer/src/profiles.ts +++ b/agent-computer/src/profiles.ts @@ -39,6 +39,11 @@ import { type BrowserContext, chromium, type Page } from "playwright"; import { profileDirectoryFor } from "./bot-id"; import { chooseEvictions, chooseIdle } from "./browser-eviction"; import { egressFor, egressLabel } from "./egress"; +import { numberFromEnv } from "./env"; + +// Re-exported so callers that already import it from here do not change, while the test imports it +// from the playwright-free `./env` instead of pulling this module's browser driver in with it. +export { numberFromEnv }; /** The viewport, which is what a person's click coordinates are relative to. */ export const VIEWPORT = { width: 1280, height: 800 }; @@ -144,21 +149,6 @@ async function closeAndWait(context: BrowserContext): Promise { await new Promise((resolve) => setTimeout(resolve, CLOSE_SETTLE_MS)); } -/** - * A number an operator set, or the default. - * - * `Number("")` is zero, and an unset variable in a compose file arrives as an empty string rather - * than as absent. Read with `??` alone, an operator who had not set the cap would get a cap of zero - * and every browser would be closed the moment it opened. Anything that is not a positive number - * falls back, because "I typed this wrong" and "I did not set it" both mean the default. - */ -function numberFromEnv(name: string, fallback: number): number { - const raw = process.env[name]?.trim(); - if (!raw) return fallback; - const value = Number(raw); - return Number.isFinite(value) && value > 0 ? value : fallback; -} - /** * How many browsers one computer holds at once. * diff --git a/agent-computer/tests/number-from-env.test.ts b/agent-computer/tests/number-from-env.test.ts new file mode 100644 index 00000000..6233aa0f --- /dev/null +++ b/agent-computer/tests/number-from-env.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { numberFromEnv } from "../src/env"; + +const NAME = "OPENBOT_TEST_NUMBER_FROM_ENV"; + +afterEach(() => { + delete process.env[NAME]; +}); + +describe("numberFromEnv", () => { + test("takes a positive number, trimming surrounding whitespace", () => { + process.env[NAME] = " 5000 "; + expect(numberFromEnv(NAME, 10000)).toBe(5000); + }); + + test("falls back when the variable is unset", () => { + expect(numberFromEnv(NAME, 10000)).toBe(10000); + }); + + test("falls back on the empty string a compose file passes for an unset variable", () => { + // The bug this guards: `Number.parseInt(process.env.X ?? "default")` sees "" here, not undefined, + // so `??` never fires and the parse is NaN. An empty value means "not set" and takes the fallback. + process.env[NAME] = ""; + expect(numberFromEnv(NAME, 10000)).toBe(10000); + }); + + test("falls back on a non-numeric value", () => { + process.env[NAME] = "soon"; + expect(numberFromEnv(NAME, 10000)).toBe(10000); + }); + + test("falls back on zero and negatives, so a bad timeout is never enforced", () => { + process.env[NAME] = "0"; + expect(numberFromEnv(NAME, 10000)).toBe(10000); + process.env[NAME] = "-5"; + expect(numberFromEnv(NAME, 10000)).toBe(10000); + }); +});